Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions services/proposal-engine/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.venv/
__pycache__/
*.pyc
.mypy_cache/
.pytest_cache/
.ruff_cache/
.coverage
*.egg-info/
66 changes: 66 additions & 0 deletions services/proposal-engine/GROUNDING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# The grounding contract — `proposal_engine`

The winniio-proposal golden rule — *"every client-specific claim traces to a
verbatim source; if it is not traceable, it does not go in"* — is now a **typed
contract**, not a prose discipline.

`parse_appendix(rows, corpus)` grounds every appendix row against the client's
own words:

| Error | Means |
|---|---|
| `UngroundedClaim` | the row's quote is **not verbatim-present** in the source it names — fabrication is a parse error |
| `MalformedRow` | missing field, wrong type, unknown source id, or a SMILE stage outside the canonical six |

A `GroundedProposal`, if it exists at all, has every row proven grounded.
`to_markdown()` renders the canonical *"what we heard → where it lands → SMILE
stage → source"* appendix table unchanged, so the skill's output is identical.

This is the **NOOA lesson** (typed returns / PredictStrategy — *no model-written
code executed*) that landed in MENTOR's judge (`Life-Atlas/mentor` #22), applied
to the proposal engine: the same shape that made a fabricated axiom a type error
makes a fabricated client claim one.

## Honest boundary (zero-gaslight)

The contract proves a quote is **present**, not that it is **faithfully
represented**. A real fragment recombined to mislead — dropping a negation,
splicing across a clause — still grounds. This limitation is pinned in
`tests/test_boundary.py`. The human roast + final consistency sweep in the skill
remain the backstop for *misquotation*. What the contract kills cold is *pure
invention* (a claim with no verbatim basis at all — the dominant LLM failure).

**Matching is deliberately forgiving on form, strict on substance.** Grounding
compares after folding typographic punctuation (curly quotes, em/en dashes,
ellipsis, nbsp), collapsing whitespace, and casefolding — so a real quote an LLM
retyped with straight ASCII punctuation or a capitalized sentence-start is not
falsely rejected. It does **not** loosen what must exist: invented text appears
in no source under any normalization.

**Still unmeasured (honest gap):** the real false-reject rate against actual
Fireflies transcripts + a real appendix has not been measured — only synthetic
sources are unit-tested. That measurement is the fast-follow before this gate is
wired into the headless flow.

## Run

```bash
pip install -e ".[dev]"
PYTHONPATH=src pytest # 26 tests: contract + boundary + api
```

HTTP gate (roadmap tier 3 — the headless door):

```bash
pip install -e ".[api]"
uvicorn proposal_engine.api:app
# POST /appendix/validate {"sources":[{id,kind,text}], "appendix":[{heard,source_id,lands,smile_stage}]}
# 200 -> {grounded:true, markdown} 422 -> {grounded:false, error_type, detail}
```

## Deliberately not built yet (KISS)

No auth, no persistence, no GraphQL, no A2A, no vector DB. The contract is the
foundation; those arrive with real tenants and a real headless flow, not before.
The generation loop itself stays in the `winniio-proposal` skill — this package
is the **gate that loop must pass** before a proposal ships.
37 changes: 37 additions & 0 deletions services/proposal-engine/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "proposal-engine"
version = "0.1.0"
description = "North Star proposal engine — the grounding contract. Every client claim traces to a verbatim source, or it is a type error."
requires-python = ">=3.11"
dependencies = []

[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.4.0",
"mypy>=1.10.0",
]
api = [
"fastapi>=0.110",
]

[tool.setuptools.packages.find]
where = ["src"]

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
ignore = ["E501"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.mypy]
ignore_missing_imports = true
30 changes: 30 additions & 0 deletions services/proposal-engine/src/proposal_engine/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""North Star proposal engine — the grounding contract.

Public surface: the typed appendix contract that makes an ungrounded proposal
claim a parse error rather than a discipline failure.
"""

from proposal_engine.appendix import (
AppendixError,
AppendixRow,
GroundedProposal,
MalformedRow,
UngroundedClaim,
parse_appendix,
)
from proposal_engine.corpus import Source, SourceCorpus
from proposal_engine.smile import SMILE_STAGES, SmileStage, is_valid_stage

__all__ = [
"AppendixError",
"AppendixRow",
"GroundedProposal",
"MalformedRow",
"UngroundedClaim",
"parse_appendix",
"Source",
"SourceCorpus",
"SMILE_STAGES",
"SmileStage",
"is_valid_stage",
]
71 changes: 71 additions & 0 deletions services/proposal-engine/src/proposal_engine/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Thin HTTP surface for the grounding contract — the headless (tier 3) door.

One endpoint: POST /appendix/validate takes {sources, appendix} and returns
either the grounded markdown table or a structured grounding error. This is the
gate the roadmap's "questionnaire JSON → FastAPI → claude -p" flow calls before
any proposal is allowed to ship. Kept deliberately minimal (KISS): no auth, no
persistence, no GraphQL — those arrive with real tenants, not before.

Requires the [api] extra (`pip install -e .[api]`); the core contract has no
web dependency, so tests and the skill can use it without FastAPI installed.
"""

from __future__ import annotations

from typing import Any

try:
from fastapi import FastAPI
from fastapi.responses import JSONResponse
except ImportError as e: # pragma: no cover - exercised only without the extra
raise ImportError("proposal_engine.api requires the [api] extra: pip install -e '.[api]'") from e

from proposal_engine.appendix import AppendixError, UngroundedClaim, parse_appendix
from proposal_engine.corpus import Source, SourceCorpus

app = FastAPI(title="North Star Proposal Engine — grounding gate", version="0.1.0")


def _build_corpus(sources: list[dict[str, Any]]) -> SourceCorpus:
return SourceCorpus.from_sources(
[Source(id=s["id"], kind=s.get("kind", "unknown"), text=s["text"]) for s in sources]
)


@app.post("/appendix/validate")
def validate_appendix(payload: dict[str, Any]) -> JSONResponse:
"""Ground an appendix against provided sources.

Body: {"sources": [{"id","kind","text"}...], "appendix": [{row}...]}.
200 -> {"grounded": true, "markdown": ...}
422 -> {"grounded": false, "error_type": ..., "detail": ...}
"""
try:
corpus = _build_corpus(payload["sources"])
except (KeyError, TypeError, ValueError) as e:
return JSONResponse(
status_code=400, content={"grounded": False, "error_type": "bad_request", "detail": str(e)}
)

try:
grounded = parse_appendix(payload["appendix"], corpus)
except UngroundedClaim as e:
return JSONResponse(
status_code=422,
content={"grounded": False, "error_type": "ungrounded_claim", "detail": str(e)},
)
except AppendixError as e:
return JSONResponse(
status_code=422,
content={"grounded": False, "error_type": "malformed", "detail": str(e)},
)
except KeyError as e:
return JSONResponse(
status_code=400,
content={"grounded": False, "error_type": "bad_request", "detail": f"missing {e}"},
)

return JSONResponse(
status_code=200,
content={"grounded": True, "rows": len(grounded.rows), "markdown": grounded.to_markdown()},
)
151 changes: 151 additions & 0 deletions services/proposal-engine/src/proposal_engine/appendix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""The appendix contract — grounding as a type, not a discipline.

The winniio-proposal appendix is the proof a proposal is grounded: one row per
client point, "what we heard → where it lands → SMILE stage", each traced to a
verbatim source. Prose discipline used to enforce it. Here it is a typed
contract: an appendix row whose quote is not verbatim-present in the named
source raises `UngroundedClaim`; a bad SMILE stage or missing field raises
`MalformedRow`. Fabrication becomes a parse error — the exact NOOA lesson
(PredictStrategy, typed returns, no model-written code executed) that landed in
MENTOR's judge, applied to the proposal engine.

`GroundedProposal.to_markdown()` renders the canonical appendix table, so the
skill's output shape is unchanged.
"""

from __future__ import annotations

import json
from dataclasses import dataclass

from proposal_engine.corpus import SourceCorpus
from proposal_engine.smile import SMILE_STAGES, SmileStage

_JSON_BLOCK_HINT = "expected a JSON array of appendix rows"


class AppendixError(ValueError):
"""Base: the appendix is not a valid, fully-grounded set of rows."""


class MalformedRow(AppendixError):
"""A row is missing a field, has a wrong type, or a bad SMILE stage."""


class UngroundedClaim(AppendixError):
"""A row's quote is not verbatim-present in the source it names — the exact
failure the golden rule exists to catch, turned into a type error."""


@dataclass(frozen=True)
class AppendixRow:
heard: str # the client's verbatim words
source_id: str # the source those words came from
lands: str # where in the proposal this point is used
smile_stage: SmileStage


@dataclass(frozen=True)
class GroundedProposal:
"""An appendix proven grounded against a corpus. If it exists, every row's
`heard` was found verbatim in its named source."""

rows: list[AppendixRow]

def to_markdown(self) -> str:
lines = [
"| What we heard | Where it lands | SMILE stage | Source |",
"|---|---|---|---|",
]
for r in self.rows:
heard = r.heard.replace("|", "\\|").replace("\n", " ").strip()
lands = r.lands.replace("|", "\\|").replace("\n", " ").strip()
lines.append(f"| {heard} | {lands} | {r.smile_stage} | {r.source_id} |")
return "\n".join(lines)


def _require(cond: bool, msg: str, exc: type[AppendixError] = MalformedRow) -> None:
if not cond:
raise exc(msg)


def _coerce_rows(raw: str | list) -> list:
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise MalformedRow(f"{_JSON_BLOCK_HINT}: {e}") from e
else:
parsed = raw
_require(isinstance(parsed, list), _JSON_BLOCK_HINT)
return parsed


def _parse_row(item: object, corpus: SourceCorpus, pos: int) -> AppendixRow:
_require(isinstance(item, dict), f"row {pos} is not an object")
assert isinstance(item, dict)
for key in ("heard", "source_id", "lands", "smile_stage"):
_require(key in item, f"row {pos} missing '{key}'")

heard = item["heard"]
source_id = item["source_id"]
lands = item["lands"]
stage = item["smile_stage"]

_require(
isinstance(heard, str) and heard.strip() != "",
f"row {pos} 'heard' must be a non-empty string",
)
_require(
isinstance(source_id, str) and source_id.strip() != "",
f"row {pos} 'source_id' must be a non-empty string",
)
_require(
isinstance(lands, str) and lands.strip() != "",
f"row {pos} 'lands' must be a non-empty string",
)
_require(
isinstance(stage, str) and stage in SMILE_STAGES,
f"row {pos} 'smile_stage' must be one of {list(SMILE_STAGES)}",
)

# Grounding, in order of specificity so the error message is the most useful:
_require(
corpus.has(source_id),
f"row {pos} names source {source_id!r}, which is not in the corpus",
)
_require(
SourceCorpus.is_substantive(heard),
f"row {pos} quote is too short/generic to ground a claim: {heard!r}",
UngroundedClaim,
)
if not corpus.contains(heard, source_id):
elsewhere = corpus.contains_anywhere(heard)
hint = (
f" (it does appear in {elsewhere!r} — name that source)"
if elsewhere
else " (not found in any source — did the client actually say this?)"
)
raise UngroundedClaim(
f"row {pos} quote is not verbatim in source {source_id!r}{hint}: {heard!r}"
)

return AppendixRow(
heard=heard.strip(),
source_id=source_id.strip(),
lands=lands.strip(),
smile_stage=stage, # type: ignore[arg-type] # validated against Literal above
)


def parse_appendix(raw: str | list, corpus: SourceCorpus) -> GroundedProposal:
"""Parse + ground an appendix against the source corpus.

Raises MalformedRow (shape/stage) or UngroundedClaim (quote not verbatim in
its named source). A caller generating the appendix from an LLM turns either
into a retry-as-observation, exactly as MENTOR's judge loop does.
"""
rows_raw = _coerce_rows(raw)
_require(len(rows_raw) >= 1, "appendix must have at least one row")
rows = [_parse_row(item, corpus, i) for i, item in enumerate(rows_raw, start=1)]
return GroundedProposal(rows=rows)
Loading
Loading