weft-generate 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- weft_generate-1.0.0/.gitignore +78 -0
- weft_generate-1.0.0/LICENSE +21 -0
- weft_generate-1.0.0/NOTICE +24 -0
- weft_generate-1.0.0/PKG-INFO +13 -0
- weft_generate-1.0.0/pyproject.toml +21 -0
- weft_generate-1.0.0/src/weft_generate/__init__.py +115 -0
- weft_generate-1.0.0/src/weft_generate/cited_answer.py +274 -0
- weft_generate-1.0.0/src/weft_generate/contract.py +58 -0
- weft_generate-1.0.0/src/weft_generate/contradiction.py +294 -0
- weft_generate-1.0.0/src/weft_generate/page.py +61 -0
- weft_generate-1.0.0/src/weft_generate/payload.py +147 -0
- weft_generate-1.0.0/src/weft_generate/prompts.py +278 -0
- weft_generate-1.0.0/src/weft_generate/py.typed +0 -0
- weft_generate-1.0.0/src/weft_generate/refine.py +398 -0
- weft_generate-1.0.0/src/weft_generate/representation.py +72 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# The donor checkout, reachable for reading and lifting only.
|
|
2
|
+
# Never a build input: see CLAUDE.md and docs/README.md.
|
|
3
|
+
/donor
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
.venv/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
|
|
13
|
+
# Tooling
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.pyright/
|
|
17
|
+
|
|
18
|
+
# `uv.lock` was here, filed between two caches. It is not a cache: `ci.yml` runs
|
|
19
|
+
# `uv sync --frozen` in three of its four jobs, and every one of them died at that step
|
|
20
|
+
# the first time CI ever ran. It is tracked now — the resolution CI installs and the
|
|
21
|
+
# resolution a developer installs must be one artefact, which is the same argument
|
|
22
|
+
# `docs/README.md` makes about single-sourcing anything two readers can disagree about.
|
|
23
|
+
#
|
|
24
|
+
# It constrains nobody downstream. A lockfile is not a dependency bound; what a consumer
|
|
25
|
+
# resolves is decided by each distribution's `pyproject.toml`.
|
|
26
|
+
|
|
27
|
+
# Session-local attempt counter for the guard_quality_gates.py PreToolUse hook — per-session
|
|
28
|
+
# scratch, not a record anyone should read later. See the hook's module docstring.
|
|
29
|
+
.claude/.gate-attempts.json
|
|
30
|
+
|
|
31
|
+
# Secrets. `.env` holds live provider keys; only the documented, valueless
|
|
32
|
+
# example is tracked. Listed before any tooling rule so a stray `git add -A`
|
|
33
|
+
# cannot reach it.
|
|
34
|
+
.env
|
|
35
|
+
.env.*
|
|
36
|
+
!.env.example
|
|
37
|
+
|
|
38
|
+
# Corpus payload. Every subdirectory of /corpus is a materialised document set and
|
|
39
|
+
# is deliberately untracked; `corpus/manifest.toml` and `scripts/fetch_corpus.py`
|
|
40
|
+
# are the tracked artefact. `09` §4 V1 permits exactly this:
|
|
41
|
+
# a corpus is "either redistributable or fetched by a pinned, checksummed script",
|
|
42
|
+
# and the mRMR papers are published under publisher copyright, so committing them
|
|
43
|
+
# would be redistribution this repository has no right to perform. The manifest
|
|
44
|
+
# carries a sha256 per document, which is what makes the set reproducible without
|
|
45
|
+
# the bytes being here. The pattern is a directory glob rather than a list of names
|
|
46
|
+
# so that scaling the corpus up cannot silently start tracking a paper.
|
|
47
|
+
/corpus/*/
|
|
48
|
+
|
|
49
|
+
# Where a baseline run stages the corpus it indexes and writes the `weft.toml` it measures
|
|
50
|
+
# through (`eval/run_baseline.py`). The staged copies are the same untracked papers one
|
|
51
|
+
# directory over, and the configuration is reproduced by the harness rather than kept — what
|
|
52
|
+
# is tracked is the run it produced, under `eval/baselines/`.
|
|
53
|
+
/.baseline-run/
|
|
54
|
+
|
|
55
|
+
# Working artefacts of a build session — a generated map of the codebase and a design
|
|
56
|
+
# record produced while planning. Untracked on purpose: `docs/README.md` routes every
|
|
57
|
+
# document this project owns, and a design that matters belongs in the `docs/` file
|
|
58
|
+
# that owns its content, not in a root-level file nothing points at.
|
|
59
|
+
/.phase2-*.md
|
|
60
|
+
/.phase3-*.md
|
|
61
|
+
/.phase4-*.md
|
|
62
|
+
|
|
63
|
+
# Gate-session preparation: the Bring lists of `docs/05-grilling-sessions.md`, measured on the day
|
|
64
|
+
# a session is about to run. Untracked for the same reason — the session's outcome belongs in
|
|
65
|
+
# `docs/README.md`'s decision log and in the reference document the decision changes, never here.
|
|
66
|
+
/.gate-brief-*.md
|
|
67
|
+
|
|
68
|
+
# Claude Code local state
|
|
69
|
+
.claude/settings.local.json
|
|
70
|
+
|
|
71
|
+
.DS_Store
|
|
72
|
+
|
|
73
|
+
# The build harness driving Phase 2, alongside its brief, findings and design record.
|
|
74
|
+
/.phase2-build.js
|
|
75
|
+
|
|
76
|
+
# Transient: harvested subagent findings, promoted into docs/lessons.md by implement-ll.
|
|
77
|
+
# Never committed — its content belongs in the queue or nowhere.
|
|
78
|
+
.claude/lessons-spool.md
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adam Krysztopa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Weft
|
|
2
|
+
Copyright (c) 2026 Adam Krysztopa
|
|
3
|
+
|
|
4
|
+
This product is licensed under the MIT License. See the LICENSE file at the root
|
|
5
|
+
of this repository.
|
|
6
|
+
|
|
7
|
+
--------------------------------------------------------------------------------
|
|
8
|
+
Original work
|
|
9
|
+
--------------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
**Weft contains no source text from any other codebase.** Every line here is
|
|
12
|
+
written for this project, against this project's contracts.
|
|
13
|
+
|
|
14
|
+
This is a rule, not a description of the current state: no file may be copied or
|
|
15
|
+
adapted from another project's source, and no third-party source text may be
|
|
16
|
+
pasted into this repository. Where a prior system informed a design, what was
|
|
17
|
+
carried across is understanding — an approach, an ordering, a measurement, a
|
|
18
|
+
reason a guard exists — restated in this project's own words and implemented
|
|
19
|
+
fresh. Copyright does not reach any of that, and nothing in this repository
|
|
20
|
+
depends on a licence granted by anyone else.
|
|
21
|
+
|
|
22
|
+
`docs/04-donor-inventory.md` records what was learned from prior work and what
|
|
23
|
+
was deliberately not taken. It is a design record. Nothing in it authorises a
|
|
24
|
+
copy, because copying is not permitted here at all.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: weft-generate
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: First-party generation pack. Publishes the Generator contract and the Answer vocabulary.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
License-File: NOTICE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: weft-kernel<1.0.0,>=0.1.0
|
|
10
|
+
Requires-Dist: weft-llm<2.0.0,>=1.0.0
|
|
11
|
+
Requires-Dist: weft-prompts<2.0.0,>=1.0.0
|
|
12
|
+
Requires-Dist: weft-retrieve<2.0.0,>=1.0.0
|
|
13
|
+
Requires-Dist: weft-store<3.0.0,>=2.0.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "weft-generate"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "First-party generation pack. Publishes the Generator contract and the Answer vocabulary."
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE", "NOTICE"]
|
|
8
|
+
dependencies = ["weft-kernel>=0.1.0,<1.0.0", "weft-store>=2.0.0,<3.0.0", "weft-retrieve>=1.0.0,<2.0.0", "weft-llm>=1.0.0,<2.0.0", "weft-prompts>=1.0.0,<2.0.0"]
|
|
9
|
+
|
|
10
|
+
# `weft-llm` and `weft-prompts` are new as of task 2.9: `cited-answer` asks a registered
|
|
11
|
+
# prompt through `weft_prompts.contract.Prompt` and answers through `weft_llm.contract.LLM`,
|
|
12
|
+
# exactly the way `weft-retrieve`'s `llm-rerank` does one pack over.
|
|
13
|
+
|
|
14
|
+
# `weft.packs` arrives with this same task: `cited-answer` is the first plugin this
|
|
15
|
+
# distribution registers, so there is finally something for the entry point to name.
|
|
16
|
+
[project.entry-points."weft.packs"]
|
|
17
|
+
generate = "weft_generate:register"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""First-party generation pack.
|
|
2
|
+
|
|
3
|
+
Publishes the `Generator` contract (`contract.py`) and the answer vocabulary
|
|
4
|
+
(`payload.py`). Task **2.4** built the mechanism and registered nothing under it, the
|
|
5
|
+
same way `weft-retrieve` did before task 2.7 — see
|
|
6
|
+
`packages/weft-retrieve/src/weft_retrieve/__init__.py` for the whole reason a `weft.packs`
|
|
7
|
+
entry point waits for a plugin to name. Task **2.9** was the first plugin here:
|
|
8
|
+
`cited-answer`, brought together with its own registered `Prompt` in one `register()`
|
|
9
|
+
call, the same shape `weft_retrieve.register` uses for `llm-rerank` and
|
|
10
|
+
`passage-relevance`. **Task 2.22 is the second**: `contradiction-check`, with its own two
|
|
11
|
+
registered prompts. **Task 2.24 is the third**: `refine-on-uncertainty`, which registers no
|
|
12
|
+
new prompt of its own — its own drafting call reuses `cited-answer`'s already-registered
|
|
13
|
+
`answer-with-citations`, and its uncertainty trigger is `weft_retrieve.contract.Sufficiency`
|
|
14
|
+
(task 2.4's contract; `weft_retrieve.sufficiency`, this same task's other half, is where
|
|
15
|
+
`llm-sufficiency` and `hedge-phrases` register), resolved by name through `StageLookup`
|
|
16
|
+
rather than imported here.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict
|
|
20
|
+
|
|
21
|
+
from weft_generate.cited_answer import NAME as CITED_ANSWER_NAME
|
|
22
|
+
from weft_generate.cited_answer import CitedAnswer, CitedAnswerConfig, WhenNoEvidence
|
|
23
|
+
from weft_generate.contract import GENERATE_CONTRACT_VERSION, Generator
|
|
24
|
+
from weft_generate.contradiction import NAME as CONTRADICTION_CHECK_NAME
|
|
25
|
+
from weft_generate.contradiction import Agreement, ContradictionCheck, ContradictionCheckConfig
|
|
26
|
+
from weft_generate.payload import Answer, AnswerStance, Citation
|
|
27
|
+
from weft_generate.prompts import (
|
|
28
|
+
ANSWER_WITH_CITATIONS_NAME,
|
|
29
|
+
CONTRADICTION_ANSWER_NAME,
|
|
30
|
+
CONTRADICTION_CRITIC_NAME,
|
|
31
|
+
AnswerWithCitationsPrompt,
|
|
32
|
+
ConflictStatus,
|
|
33
|
+
ContradictionAnswerPrompt,
|
|
34
|
+
ContradictionCriticPrompt,
|
|
35
|
+
)
|
|
36
|
+
from weft_generate.refine import NAME as REFINE_ON_UNCERTAINTY_NAME
|
|
37
|
+
from weft_generate.refine import (
|
|
38
|
+
RefinementStop,
|
|
39
|
+
RefinementTrace,
|
|
40
|
+
RefineOnUncertainty,
|
|
41
|
+
RefineOnUncertaintyConfig,
|
|
42
|
+
refinement_stop,
|
|
43
|
+
)
|
|
44
|
+
from weft_kernel.discovery import PackRegistrar
|
|
45
|
+
from weft_prompts.contract import Prompt
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Settings(BaseModel):
|
|
49
|
+
"""`weft-generate` takes no pack settings — an empty model is still the required shape."""
|
|
50
|
+
|
|
51
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register(registrar: PackRegistrar, settings: Settings) -> None:
|
|
55
|
+
"""Register every `Generator` this pack ships, and the prompts each one asks under
|
|
56
|
+
`Prompt`.
|
|
57
|
+
|
|
58
|
+
Every `Prompt` goes through the same `registrar` as its owning `Generator` —
|
|
59
|
+
`weft_retrieve.__init__`'s own docstring states why a first-party prompt belongs to
|
|
60
|
+
the plugin that asks the question rather than to `weft-prompts`, which registers
|
|
61
|
+
nothing at all.
|
|
62
|
+
|
|
63
|
+
**`Agreement` and `RefinementTrace` are deliberately not passed to `registrar.
|
|
64
|
+
add_ext_model` — task 5.2g's own finding, not an oversight.** Both attach to
|
|
65
|
+
`Answer.ext`, never to `Node.ext`, and only a `Node` is ever handed to a `NodeStore` —
|
|
66
|
+
`weft_store.rehydrate.rehydrate_ext` reconstructs a *node's* `ext` map and is never
|
|
67
|
+
called with an `Answer`'s. Registering both would also collide: they share
|
|
68
|
+
`__namespace__ = "weft-generate"`, so `weft_store.rehydrate.ext_models` — one class
|
|
69
|
+
per namespace, globally — would raise `DuplicateRegistrationError` the moment both
|
|
70
|
+
are active, which `contradiction-check` and `refine-on-uncertainty` together already
|
|
71
|
+
are in this pack's own default pipelines. `docs/lessons.md` L5.20 records this as the
|
|
72
|
+
reason `add_ext_model` is for an `ExtModel` that reaches a `Node`, not for every
|
|
73
|
+
`ExtModel` a pack happens to own — see `weft_retrieve.__init__`'s own module
|
|
74
|
+
docstring for the identical finding against `BooleanPlan`/`CorrectiveTrace`/
|
|
75
|
+
`IterativeRetrievalTrace`.
|
|
76
|
+
"""
|
|
77
|
+
del settings
|
|
78
|
+
registrar.add(Generator, CITED_ANSWER_NAME, CitedAnswer)
|
|
79
|
+
registrar.add(Prompt, ANSWER_WITH_CITATIONS_NAME, AnswerWithCitationsPrompt)
|
|
80
|
+
registrar.add(Generator, CONTRADICTION_CHECK_NAME, ContradictionCheck)
|
|
81
|
+
registrar.add(Prompt, CONTRADICTION_CRITIC_NAME, ContradictionCriticPrompt)
|
|
82
|
+
registrar.add(Prompt, CONTRADICTION_ANSWER_NAME, ContradictionAnswerPrompt)
|
|
83
|
+
registrar.add(Generator, REFINE_ON_UNCERTAINTY_NAME, RefineOnUncertainty)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"ANSWER_WITH_CITATIONS_NAME",
|
|
88
|
+
"CITED_ANSWER_NAME",
|
|
89
|
+
"CONTRADICTION_ANSWER_NAME",
|
|
90
|
+
"CONTRADICTION_CHECK_NAME",
|
|
91
|
+
"CONTRADICTION_CRITIC_NAME",
|
|
92
|
+
"GENERATE_CONTRACT_VERSION",
|
|
93
|
+
"REFINE_ON_UNCERTAINTY_NAME",
|
|
94
|
+
"Agreement",
|
|
95
|
+
"Answer",
|
|
96
|
+
"AnswerStance",
|
|
97
|
+
"AnswerWithCitationsPrompt",
|
|
98
|
+
"Citation",
|
|
99
|
+
"CitedAnswer",
|
|
100
|
+
"CitedAnswerConfig",
|
|
101
|
+
"ConflictStatus",
|
|
102
|
+
"ContradictionAnswerPrompt",
|
|
103
|
+
"ContradictionCheck",
|
|
104
|
+
"ContradictionCheckConfig",
|
|
105
|
+
"ContradictionCriticPrompt",
|
|
106
|
+
"Generator",
|
|
107
|
+
"RefineOnUncertainty",
|
|
108
|
+
"RefineOnUncertaintyConfig",
|
|
109
|
+
"RefinementStop",
|
|
110
|
+
"RefinementTrace",
|
|
111
|
+
"Settings",
|
|
112
|
+
"WhenNoEvidence",
|
|
113
|
+
"refinement_stop",
|
|
114
|
+
"register",
|
|
115
|
+
]
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""`cited-answer` — the one `Generator` this task ships. `Stage[Passages, Answer]`.
|
|
2
|
+
|
|
3
|
+
Task **2.9**, `docs/build-ledger.md`: "an answer carries citations a reader can follow
|
|
4
|
+
back to a passage — not to a document, and not to a paraphrase." `weft_generate.payload.
|
|
5
|
+
Answer._citations_resolve` already makes the *shape* of that unconstructable to violate;
|
|
6
|
+
this module is what builds an `Answer` in the first place, and what keeps the marker a
|
|
7
|
+
model writes and the passage `Answer.used` records honest with each other.
|
|
8
|
+
|
|
9
|
+
**One plugin, two behaviours, configuration — `01` requirement 6's second clause instead
|
|
10
|
+
of a second generator name.** `when_no_evidence` decides what happens when `Passages` is
|
|
11
|
+
empty: `REFUSE` answers `Answer(stance=NOT_IN_CORPUS)` and calls no model at all — the
|
|
12
|
+
line `.phase2-design.md` §3 states outright, "`cited-answer` is the stage that turns
|
|
13
|
+
empty evidence into `Answer(stance=NOT_IN_CORPUS)`" — and `ANSWER_FROM_MEMORY` asks the
|
|
14
|
+
same prompt with no passages shown, which is what `no-retrieval`'s own pipeline sets.
|
|
15
|
+
|
|
16
|
+
**Citations are extracted from the completion, never asked for as a second structured
|
|
17
|
+
field.** `docs/04-donor-inventory.md`'s corrected `CitationManager` row records the
|
|
18
|
+
donor's second responsibility as "extract which sources were actually cited" through
|
|
19
|
+
four ordered, language-specific regexes over freeform text. This build does not need
|
|
20
|
+
them: `weft_retrieve.payload.Passages` already assigns every offered passage an exact,
|
|
21
|
+
known label before a model ever sees one, so "which sources were cited" is answered by a
|
|
22
|
+
literal substring search for `[label]` per offered passage — no language heuristic, and
|
|
23
|
+
no way for the search to disagree with what was actually shown, because the label
|
|
24
|
+
searched for and the label shown are the same string. This also settles a question the
|
|
25
|
+
donor's approach could not: a model is asked for prose, not for a second list of markers
|
|
26
|
+
that could name a passage never offered or drop one it clearly used — the two ways
|
|
27
|
+
`weft_retrieve.rerank`'s judgement-set check refuses a reranker's reply, in a shape this
|
|
28
|
+
plugin cannot have because it never asks for that second list.
|
|
29
|
+
|
|
30
|
+
**Page numbers are `weft_generate.page.page_for`'s, not this module's.** Every citation
|
|
31
|
+
this plugin builds carries whatever that function resolves for the cited node — `None`
|
|
32
|
+
when nothing in the pipeline attached the two facts it needs. See that module for why
|
|
33
|
+
`cited_answer.py` imports neither `weft_chunk` nor `weft_pdf` to get there.
|
|
34
|
+
|
|
35
|
+
**Task 2.31's own obligation, closed here — and it turned out to reach further than a
|
|
36
|
+
`Citation` field.** `.phase2-findings.md` §11: "a hit on any of them resolves through
|
|
37
|
+
lineage to the one parent chunk... which is what makes task 2.9's citations able to follow
|
|
38
|
+
back to a passage rather than to a paraphrase of one." The first attempt patched
|
|
39
|
+
`Citation.node_id` alone and `Answer._citations_resolve` refused it outright: that
|
|
40
|
+
validator requires a citation's `node_id` to be the id of the very passage `used` records
|
|
41
|
+
under the marker naming it, by construction, so a `Citation` cannot point at a different
|
|
42
|
+
node than the `Passage` it is paired with — the renumbering bug it exists to make
|
|
43
|
+
unconstructable is exactly the shape a citation-only patch would have produced. So the
|
|
44
|
+
resolution moves one step earlier: `_resolved_to_citable`, below, substitutes a
|
|
45
|
+
representation's own node for the one it stands in for — via `weft_generate.
|
|
46
|
+
representation.citable_nodes`, the same duck-typed, no-new-dependency read `page_for`
|
|
47
|
+
already sets a precedent for — on `offered` itself, before it becomes `_offer`'s prompt
|
|
48
|
+
text, before it becomes `Answer.used`, and before a `Citation` is built from it. One
|
|
49
|
+
substitution, three consumers agreeing, rather than three places that could disagree.
|
|
50
|
+
This also closes the corollary gap for free: a model asked to answer from a retrieved
|
|
51
|
+
question's own wording would have been shown that wording as "evidence"; now it reads the
|
|
52
|
+
real passage the representation stands in for, the same as everything else it is offered.
|
|
53
|
+
|
|
54
|
+
**What this does not do.** Two representations of the *same* parent occupying two offered
|
|
55
|
+
slots still occupy two — this substitutes identity, node for node, and applies no score
|
|
56
|
+
policy across slots. Collapsing that duplication before it reaches this stage at all is
|
|
57
|
+
`10` §1.2's `collapse-to-parent` row (ledger task 2.33), which owns a `Ranking`-level
|
|
58
|
+
merge policy this plugin's own 1:1 substitution neither needs nor should guess at.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from collections.abc import Iterable
|
|
62
|
+
from enum import StrEnum
|
|
63
|
+
from typing import ClassVar
|
|
64
|
+
|
|
65
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
66
|
+
|
|
67
|
+
from weft_generate.page import page_for
|
|
68
|
+
from weft_generate.payload import Answer, AnswerStance, Citation
|
|
69
|
+
from weft_generate.prompts import ANSWER_WITH_CITATIONS_NAME, AnswerWithCitationsRequest
|
|
70
|
+
from weft_generate.representation import citable_nodes
|
|
71
|
+
from weft_kernel.context import Context
|
|
72
|
+
from weft_kernel.payload import Node, Outcome, Produced, SourceId
|
|
73
|
+
from weft_llm.contract import LLM
|
|
74
|
+
from weft_prompts.contract import Prompt
|
|
75
|
+
from weft_retrieve.contract import StageLookup
|
|
76
|
+
from weft_retrieve.payload import Passage, Passages
|
|
77
|
+
from weft_store.contract import NodeStore, Scored
|
|
78
|
+
|
|
79
|
+
#: The name this generator is registered and selectable under — see `weft_generate.register`.
|
|
80
|
+
NAME = "cited-answer"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class WhenNoEvidence(StrEnum):
|
|
84
|
+
"""What `cited-answer` does when `Passages.passages` is empty. `Enum`, project rule."""
|
|
85
|
+
|
|
86
|
+
#: `Answer(stance=NOT_IN_CORPUS)`, no model call — the default, and what "the corpus
|
|
87
|
+
#: does not contain it" means when nothing was ever retrieved to check.
|
|
88
|
+
REFUSE = "refuse"
|
|
89
|
+
#: Ask the model anyway, with no passages shown — what `no-retrieval`'s own pipeline
|
|
90
|
+
#: sets, because the null-retrieval branch is a deliberate choice to answer from
|
|
91
|
+
#: parametric memory (Roberts, Raffel & Shazeer 2020), not a retrieval failure.
|
|
92
|
+
ANSWER_FROM_MEMORY = "answer_from_memory"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CitedAnswerConfig(BaseModel):
|
|
96
|
+
"""`CitedAnswer`'s `with:` config. Every field has a default, per this pack's own rule."""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
99
|
+
|
|
100
|
+
prompt: str = Field(default=ANSWER_WITH_CITATIONS_NAME, min_length=1)
|
|
101
|
+
role: str = Field(default="generate", min_length=1)
|
|
102
|
+
max_passages: int = Field(default=8, ge=1)
|
|
103
|
+
#: Presents the offered evidence grouped under the retriever that found it, rather
|
|
104
|
+
#: than as one flat numbered list — useful once a fan-out or hybrid pipeline feeds
|
|
105
|
+
#: this stage passages `Passage.retrieved_by` disagrees about.
|
|
106
|
+
group_by_retriever: bool = False
|
|
107
|
+
when_no_evidence: WhenNoEvidence = WhenNoEvidence.REFUSE
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class CitedAnswer:
|
|
111
|
+
"""Turns packed, labelled evidence into a cited answer. Satisfies `contract.Generator`
|
|
112
|
+
structurally.
|
|
113
|
+
|
|
114
|
+
`cost_bound = (0, 1)` — zero when evidence is empty and `when_no_evidence=REFUSE`,
|
|
115
|
+
because `run` returns before resolving anything; one otherwise, the same arithmetic
|
|
116
|
+
`weft_retrieve.rerank.LlmRerank` states for its own single-call ceiling.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
config_model: ClassVar[type[CitedAnswerConfig]] = CitedAnswerConfig
|
|
120
|
+
cost_bound: ClassVar[tuple[int, int]] = (0, 1)
|
|
121
|
+
|
|
122
|
+
def __init__(self, config: CitedAnswerConfig | None = None) -> None:
|
|
123
|
+
self._config = config if config is not None else CitedAnswerConfig()
|
|
124
|
+
|
|
125
|
+
async def run(self, payload: Passages, ctx: Context) -> Outcome[Answer]:
|
|
126
|
+
"""Ask under `origin` alone, never a derived query — `QuerySet.origin`'s own rule,
|
|
127
|
+
applied at the last stage on the query path rather than only the first.
|
|
128
|
+
"""
|
|
129
|
+
if not payload.passages:
|
|
130
|
+
if self._config.when_no_evidence is WhenNoEvidence.REFUSE:
|
|
131
|
+
return Produced(
|
|
132
|
+
value=Answer(
|
|
133
|
+
origin=payload.origin,
|
|
134
|
+
text="",
|
|
135
|
+
citations=(),
|
|
136
|
+
used=(),
|
|
137
|
+
stance=AnswerStance.NOT_IN_CORPUS,
|
|
138
|
+
answered_by=NAME,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
offered: tuple[Passage, ...] = ()
|
|
142
|
+
else:
|
|
143
|
+
offered = await _resolved_to_citable(
|
|
144
|
+
payload.passages[: self._config.max_passages], ctx=ctx
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
llm = ctx.require(LLM)
|
|
148
|
+
lookup = ctx.require(StageLookup)
|
|
149
|
+
prompt = await lookup.build_capability(Prompt, self._config.prompt)
|
|
150
|
+
request = AnswerWithCitationsRequest(
|
|
151
|
+
question=payload.origin.text,
|
|
152
|
+
passages=_offer(offered, group_by_retriever=self._config.group_by_retriever),
|
|
153
|
+
)
|
|
154
|
+
rendered = await prompt.render(request, ctx)
|
|
155
|
+
if not isinstance(rendered, Produced):
|
|
156
|
+
# A prompt with nothing to ask is relayed exactly as it answered — the same
|
|
157
|
+
# rule `weft_prompts.cascade.execute` and `weft_retrieve.rerank` both take.
|
|
158
|
+
return rendered
|
|
159
|
+
|
|
160
|
+
completion = await llm.complete(rendered.value, role=self._config.role, ctx=ctx)
|
|
161
|
+
if not isinstance(completion, Produced):
|
|
162
|
+
return completion
|
|
163
|
+
text = completion.value.text
|
|
164
|
+
|
|
165
|
+
citations = await _citations_for(offered, text=text, ctx=ctx)
|
|
166
|
+
return Produced(
|
|
167
|
+
value=Answer(
|
|
168
|
+
origin=payload.origin,
|
|
169
|
+
text=text,
|
|
170
|
+
citations=citations,
|
|
171
|
+
used=offered,
|
|
172
|
+
stance=AnswerStance.ANSWERED,
|
|
173
|
+
answered_by=NAME,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _offer(passages: tuple[Passage, ...], *, group_by_retriever: bool) -> str:
|
|
179
|
+
"""The offered evidence as one string, numbered by each passage's own `label`.
|
|
180
|
+
|
|
181
|
+
Never a re-derived index — see `weft_generate.prompts`' module docstring for why that
|
|
182
|
+
is the one thing this function must not do.
|
|
183
|
+
"""
|
|
184
|
+
if not passages:
|
|
185
|
+
return ""
|
|
186
|
+
if not group_by_retriever:
|
|
187
|
+
return "\n\n".join(f"[{passage.label}] {passage.node.content}" for passage in passages)
|
|
188
|
+
groups: dict[str, list[Passage]] = {}
|
|
189
|
+
for passage in passages:
|
|
190
|
+
groups.setdefault(passage.retrieved_by, []).append(passage)
|
|
191
|
+
blocks = [
|
|
192
|
+
f"From {retriever}:\n"
|
|
193
|
+
+ "\n\n".join(f"[{passage.label}] {passage.node.content}" for passage in group)
|
|
194
|
+
for retriever, group in sorted(groups.items())
|
|
195
|
+
]
|
|
196
|
+
return "\n\n".join(blocks)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def _resolved_to_citable(
|
|
200
|
+
offered: tuple[Passage, ...], *, ctx: Context
|
|
201
|
+
) -> tuple[Passage, ...]:
|
|
202
|
+
"""`offered`, with every representation's own node substituted for the one it stands in
|
|
203
|
+
for — see the module docstring for why this runs once, here, rather than inside
|
|
204
|
+
`_citations_for` alone. `rank`, `retrieved_by` and `label` are untouched; only which
|
|
205
|
+
node the passage carries changes, via a fresh `Scored` around the resolved node and the
|
|
206
|
+
passage's own original `score`.
|
|
207
|
+
"""
|
|
208
|
+
if not offered:
|
|
209
|
+
return offered
|
|
210
|
+
store = ctx.require(NodeStore)
|
|
211
|
+
resolved = await citable_nodes((passage.node for passage in offered), store=store)
|
|
212
|
+
return tuple(
|
|
213
|
+
passage
|
|
214
|
+
if resolved[passage.node.id] is passage.node
|
|
215
|
+
else Passage(
|
|
216
|
+
scored=Scored(value=resolved[passage.node.id], score=passage.score),
|
|
217
|
+
rank=passage.rank,
|
|
218
|
+
retrieved_by=passage.retrieved_by,
|
|
219
|
+
label=passage.label,
|
|
220
|
+
)
|
|
221
|
+
for passage in offered
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def _citations_for(
|
|
226
|
+
offered: tuple[Passage, ...], *, text: str, ctx: Context
|
|
227
|
+
) -> tuple[Citation, ...]:
|
|
228
|
+
"""One `Citation` per offered passage whose own bracketed label appears in `text`.
|
|
229
|
+
|
|
230
|
+
A literal substring search, not a regex over prose: the label searched for is the
|
|
231
|
+
exact string shown to the model, so there is nothing for a language-specific pattern
|
|
232
|
+
to get right or wrong that this search does not already get right by construction.
|
|
233
|
+
`offered` has already been resolved to citable nodes by `run`, so `passage.node` here
|
|
234
|
+
is always the node a `Citation` should name.
|
|
235
|
+
"""
|
|
236
|
+
cited = [
|
|
237
|
+
(passage, _source_id(passage.node)) for passage in offered if f"[{passage.label}]" in text
|
|
238
|
+
]
|
|
239
|
+
store = ctx.require(NodeStore)
|
|
240
|
+
uris = await _uris_for((source_id for _, source_id in cited), store=store)
|
|
241
|
+
return tuple(
|
|
242
|
+
Citation(
|
|
243
|
+
marker=passage.label,
|
|
244
|
+
node_id=passage.node.id,
|
|
245
|
+
source_id=source_id,
|
|
246
|
+
uri=uris.get(source_id, "") if source_id is not None else "",
|
|
247
|
+
page=page_for(passage.node),
|
|
248
|
+
)
|
|
249
|
+
for passage, source_id in cited
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def _uris_for(
|
|
254
|
+
source_ids: Iterable[SourceId | None], *, store: NodeStore
|
|
255
|
+
) -> dict[SourceId, str]:
|
|
256
|
+
"""Every distinct known source's `uri`, looked up once each rather than once per citation."""
|
|
257
|
+
wanted = {source_id for source_id in source_ids if source_id is not None}
|
|
258
|
+
uris: dict[SourceId, str] = {}
|
|
259
|
+
for source_id in wanted:
|
|
260
|
+
record = await store.get_source(source_id)
|
|
261
|
+
uris[source_id] = record.uri if record is not None else ""
|
|
262
|
+
return uris
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _source_id(node: Node) -> SourceId | None:
|
|
266
|
+
"""`node`'s one source, or `None` when it has none or more than one to choose from.
|
|
267
|
+
|
|
268
|
+
Zero is `Node.synthetic`'s legitimate root case — `Citation.source_id`'s own docstring.
|
|
269
|
+
More than one is a summary node (`Node.combine`): naming one of several documents as
|
|
270
|
+
*the* source would be a citation that misattributes a claim to a document it might not
|
|
271
|
+
actually rest on, which is worse than a citation that says only "this is a summary".
|
|
272
|
+
"""
|
|
273
|
+
sources = node.lineage.sources
|
|
274
|
+
return next(iter(sources)) if len(sources) == 1 else None
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The `Generator` contract — published here, never by the kernel.
|
|
2
|
+
|
|
3
|
+
Task **2.4**. One `Stage` subtype, closing the query path: `Stage[Passages, Answer]`,
|
|
4
|
+
domain types on both sides, and the last seam `weft_kernel.runner._check_composition`
|
|
5
|
+
checks before a run produces something a reader sees.
|
|
6
|
+
|
|
7
|
+
**Why this is a separate distribution from `weft-retrieve`.** G3 settled that discovery is
|
|
8
|
+
*eager* — installed means `register()` runs on every registry-touching command — so a
|
|
9
|
+
retrieval-only deployment (rerank-and-return enterprise search) must be able to *not
|
|
10
|
+
install* a generator, its prompts and its citation machinery rather than merely not name
|
|
11
|
+
them. `Answer` is also the only Phase 2 type an embedding host application consumes, and
|
|
12
|
+
a host that wants it should not thereby acquire every retrieval technique this project
|
|
13
|
+
ships.
|
|
14
|
+
|
|
15
|
+
`Passages` and `Query` come from `weft-retrieve`, which this pack depends on: dependencies
|
|
16
|
+
flow one way along the pipeline and never back. See `weft_generate.payload`'s module
|
|
17
|
+
docstring for why `Answer` is published *here* even though a retrieval-shaped technique
|
|
18
|
+
wants it.
|
|
19
|
+
|
|
20
|
+
The emptiness rule, the `applies_to` note and the `publishes_property_vocabulary`
|
|
21
|
+
declining argument in `weft_retrieve.contract`'s module docstring all apply verbatim to
|
|
22
|
+
this contract; they are stated there once rather than copied.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from typing import TYPE_CHECKING, ClassVar, Protocol, runtime_checkable
|
|
26
|
+
|
|
27
|
+
from weft_generate.payload import Answer
|
|
28
|
+
from weft_kernel.context import Context
|
|
29
|
+
from weft_kernel.payload import Outcome
|
|
30
|
+
from weft_kernel.runner import Stage
|
|
31
|
+
from weft_retrieve.payload import Passages
|
|
32
|
+
|
|
33
|
+
#: Fitness function 6's subject for this contract — see the module docstring.
|
|
34
|
+
GENERATE_CONTRACT_VERSION = "1.0.0"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class Generator(Stage[Passages, Answer], Protocol):
|
|
39
|
+
"""Turns packed, labelled evidence into an answer that cites it.
|
|
40
|
+
|
|
41
|
+
Taking `Passages` rather than a `Ranking` is what makes task 2.9 mechanical: the
|
|
42
|
+
citation labels are already assigned and final by the time a generator sees them, so
|
|
43
|
+
`Answer`'s own validator can refuse a marker that resolves to nothing. A generator
|
|
44
|
+
that decides the evidence does not answer the question returns
|
|
45
|
+
`Answer(stance=NOT_IN_CORPUS)` — `Produced`, with an honest claim inside it — never
|
|
46
|
+
`NothingToProduce`, which would stop the pipeline and leave the caller with no answer
|
|
47
|
+
at all where `09` §4's V2 requires one.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
#: Declared only for a type checker, assigned for real after the class body, so it
|
|
52
|
+
#: never joins `__protocol_attrs__`.
|
|
53
|
+
version: ClassVar[str]
|
|
54
|
+
|
|
55
|
+
async def run(self, payload: Passages, ctx: Context) -> Outcome[Answer]: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
Generator.version = GENERATE_CONTRACT_VERSION
|