Add versioned task context capsules
This commit is contained in:
parent
34cd5f74c1
commit
4cc6277054
18 changed files with 2834 additions and 10 deletions
|
|
@ -14,7 +14,7 @@ from collections.abc import Callable, Generator
|
|||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from typing import Literal, cast
|
||||
|
||||
from .errors import DocForgeError
|
||||
from .models import (
|
||||
|
|
@ -32,11 +32,25 @@ from .models import (
|
|||
ProjectSnapshot,
|
||||
ProjectState,
|
||||
)
|
||||
from .pagination import canonical_hash
|
||||
from .project import project_root_fingerprint
|
||||
from .retrieval import (
|
||||
CapsuleEvidenceV1,
|
||||
CapsuleOmissionV1,
|
||||
CapsuleRelationshipV1,
|
||||
ContextCapsuleV1,
|
||||
EvidenceGapV1,
|
||||
RetrievalPlanV1,
|
||||
capsule_evidence,
|
||||
finalize_capsule,
|
||||
relation_category,
|
||||
validate_retrieval_plan,
|
||||
)
|
||||
from .telemetry import increment, stage
|
||||
|
||||
INDEX_SCHEMA_VERSION = 3
|
||||
APPLICATION_ID = 1_146_683_778
|
||||
_SQLITE_PARAMETER_CHUNK = 500
|
||||
|
||||
|
||||
def _node_hash(nodes: tuple[Node, ...]) -> str:
|
||||
|
|
@ -147,6 +161,17 @@ class _IndexReadSnapshot:
|
|||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TaskSelection:
|
||||
node_id: str
|
||||
role: str
|
||||
reason_code: str
|
||||
depth: int
|
||||
category_rank: int
|
||||
first_edge: tuple[str, str, str]
|
||||
relationship_path: tuple[CapsuleRelationshipV1, ...]
|
||||
|
||||
|
||||
class ProjectIndex:
|
||||
"""A disposable index that always checks current canonical source before queries."""
|
||||
|
||||
|
|
@ -798,6 +823,418 @@ class ProjectIndex:
|
|||
status.st_ctime_ns,
|
||||
)
|
||||
|
||||
def task_context(self, plan: RetrievalPlanV1) -> dict[str, object]:
|
||||
"""Execute one fixed task plan inside one immutable index generation."""
|
||||
|
||||
validate_retrieval_plan(plan, self.project.descriptor)
|
||||
with self._read_snapshot() as snapshot:
|
||||
capsule = self._task_context_capsule(snapshot, plan)
|
||||
return snapshot.result(capsule=capsule.as_dict())
|
||||
|
||||
def _task_context_capsule(
|
||||
self,
|
||||
snapshot: _IndexReadSnapshot,
|
||||
plan: RetrievalPlanV1,
|
||||
) -> ContextCapsuleV1:
|
||||
generation = {
|
||||
"project_id": snapshot.checked["project_id"],
|
||||
"project_root_fingerprint": snapshot.checked["project_root_fingerprint"],
|
||||
"adapter": snapshot.checked["adapter"],
|
||||
"revision": snapshot.checked["revision"],
|
||||
"source_hash": snapshot.checked["source_hash"],
|
||||
"index_schema_version": INDEX_SCHEMA_VERSION,
|
||||
}
|
||||
focus_rows: list[sqlite3.Row]
|
||||
focus_reason = "exact_focus"
|
||||
if plan.focus_node_id is not None:
|
||||
row = snapshot.connection.execute(
|
||||
"SELECT * FROM nodes WHERE node_id = ?",
|
||||
(plan.focus_node_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise DocForgeError(
|
||||
"missing_node",
|
||||
"No node has the requested stable ID",
|
||||
node_id=plan.focus_node_id,
|
||||
)
|
||||
focus_rows = [row]
|
||||
else:
|
||||
terms = re_tokenize(plan.task_query)
|
||||
if not terms:
|
||||
raise DocForgeError(
|
||||
"invalid_task_focus",
|
||||
"Task description contains no searchable text",
|
||||
)
|
||||
expression = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in terms)
|
||||
focus_rows = snapshot.connection.execute(
|
||||
"""
|
||||
SELECT nodes.*, bm25(node_fts) AS rank
|
||||
FROM node_fts JOIN nodes USING(node_id)
|
||||
WHERE node_fts MATCH ?
|
||||
ORDER BY rank, nodes.node_id
|
||||
LIMIT ?
|
||||
""",
|
||||
(expression, plan.max_evidence + 1),
|
||||
).fetchall()
|
||||
focus_reason = "lexical_focus"
|
||||
if not focus_rows:
|
||||
return finalize_capsule(
|
||||
state="blocked",
|
||||
task_kind=plan.task_kind,
|
||||
generation=generation,
|
||||
plan=plan,
|
||||
focus_state="not_found",
|
||||
focus_node_id=None,
|
||||
focus_candidate_count=0,
|
||||
evidence=(),
|
||||
gaps=(
|
||||
EvidenceGapV1(
|
||||
code="focus_not_found",
|
||||
requirement_id="focus",
|
||||
category=None,
|
||||
state="blocked",
|
||||
check_complete=True,
|
||||
detail="No indexed node matched the bounded lexical focus.",
|
||||
),
|
||||
),
|
||||
omissions=(),
|
||||
selected_count=0,
|
||||
examined_edge_count=0,
|
||||
estimated_tokens=0,
|
||||
unclassified_relations=(),
|
||||
)
|
||||
if len(focus_rows) > 1 and focus_rows[0]["rank"] == focus_rows[1]["rank"]:
|
||||
return finalize_capsule(
|
||||
state="blocked",
|
||||
task_kind=plan.task_kind,
|
||||
generation=generation,
|
||||
plan=plan,
|
||||
focus_state="ambiguous",
|
||||
focus_node_id=None,
|
||||
focus_candidate_count=len(focus_rows),
|
||||
evidence=(),
|
||||
gaps=(
|
||||
EvidenceGapV1(
|
||||
code="focus_ambiguous",
|
||||
requirement_id="focus",
|
||||
category=None,
|
||||
state="blocked",
|
||||
check_complete=True,
|
||||
detail=(
|
||||
"The highest-ranked lexical focus is tied; provide focus_node_id."
|
||||
),
|
||||
),
|
||||
),
|
||||
omissions=(),
|
||||
selected_count=0,
|
||||
examined_edge_count=0,
|
||||
estimated_tokens=0,
|
||||
unclassified_relations=(),
|
||||
)
|
||||
focus_rows = focus_rows[:1]
|
||||
|
||||
focus_node_id = cast(str, focus_rows[0]["node_id"])
|
||||
selections: dict[str, _TaskSelection] = {
|
||||
focus_node_id: _TaskSelection(
|
||||
node_id=focus_node_id,
|
||||
role="focus",
|
||||
reason_code=focus_reason,
|
||||
depth=0,
|
||||
category_rank=-1,
|
||||
first_edge=("", "", ""),
|
||||
relationship_path=(),
|
||||
)
|
||||
}
|
||||
relationship_reasons: dict[str, set[CapsuleRelationshipV1]] = {focus_node_id: set()}
|
||||
queue: deque[str] = deque((focus_node_id,))
|
||||
examined_edge_count = 0
|
||||
traversal_incomplete = False
|
||||
edge_examination_limit_reached = False
|
||||
first_omitted_node: str | None = None
|
||||
unclassified_relations: set[str] = set()
|
||||
while queue and not traversal_incomplete:
|
||||
current = queue.popleft()
|
||||
current_selection = selections[current]
|
||||
if current_selection.depth >= plan.max_depth:
|
||||
continue
|
||||
remaining = plan.max_candidate_edges - examined_edge_count
|
||||
if remaining <= 0:
|
||||
traversal_incomplete = True
|
||||
edge_examination_limit_reached = True
|
||||
break
|
||||
outgoing = snapshot.connection.execute(
|
||||
"SELECT source_id, relation, target_id FROM edges "
|
||||
"WHERE source_id = ? ORDER BY source_id, relation, target_id LIMIT ?",
|
||||
(current, remaining + 1),
|
||||
).fetchall()
|
||||
incoming = snapshot.connection.execute(
|
||||
"SELECT source_id, relation, target_id FROM edges "
|
||||
"WHERE target_id = ? ORDER BY source_id, relation, target_id LIMIT ?",
|
||||
(current, remaining + 1),
|
||||
).fetchall()
|
||||
candidates = {
|
||||
(
|
||||
cast(str, row["source_id"]),
|
||||
cast(str, row["relation"]),
|
||||
cast(str, row["target_id"]),
|
||||
"outgoing" if row["source_id"] == current else "incoming",
|
||||
)
|
||||
for row in (*outgoing, *incoming)
|
||||
}
|
||||
ordered = sorted(
|
||||
candidates,
|
||||
key=lambda item: (
|
||||
plan.category_order.index(relation_category(item[1])),
|
||||
item[0],
|
||||
item[1],
|
||||
item[2],
|
||||
item[3],
|
||||
),
|
||||
)
|
||||
if len(ordered) > remaining:
|
||||
traversal_incomplete = True
|
||||
edge_examination_limit_reached = True
|
||||
ordered = ordered[:remaining]
|
||||
for source_id, relation, target_id, direction in ordered:
|
||||
examined_edge_count += 1
|
||||
category = relation_category(relation)
|
||||
if category == "unclassified":
|
||||
unclassified_relations.add(relation)
|
||||
neighbor = target_id if source_id == current else source_id
|
||||
relationship = CapsuleRelationshipV1(
|
||||
source_id=source_id,
|
||||
relation=relation,
|
||||
target_id=target_id,
|
||||
direction=cast(Literal["outgoing", "incoming"], direction),
|
||||
category=category,
|
||||
)
|
||||
evidence_reason = CapsuleRelationshipV1(
|
||||
source_id=source_id,
|
||||
relation=relation,
|
||||
target_id=target_id,
|
||||
direction="outgoing" if source_id == neighbor else "incoming",
|
||||
category=category,
|
||||
)
|
||||
if neighbor in selections:
|
||||
relationship_reasons.setdefault(neighbor, set()).add(evidence_reason)
|
||||
continue
|
||||
if len(selections) >= plan.max_evidence:
|
||||
traversal_incomplete = True
|
||||
first_omitted_node = neighbor
|
||||
break
|
||||
selections[neighbor] = _TaskSelection(
|
||||
node_id=neighbor,
|
||||
role="related",
|
||||
reason_code="relationship_path",
|
||||
depth=current_selection.depth + 1,
|
||||
category_rank=plan.category_order.index(category),
|
||||
first_edge=(source_id, relation, target_id),
|
||||
relationship_path=(
|
||||
*current_selection.relationship_path,
|
||||
relationship,
|
||||
),
|
||||
)
|
||||
relationship_reasons[neighbor] = {evidence_reason}
|
||||
queue.append(neighbor)
|
||||
|
||||
ordered_selections = sorted(
|
||||
selections.values(),
|
||||
key=lambda selection: (
|
||||
0 if selection.role == "focus" else 1,
|
||||
selection.category_rank,
|
||||
selection.depth,
|
||||
selection.first_edge,
|
||||
selection.node_id,
|
||||
),
|
||||
)
|
||||
selected_node_ids = tuple(selection.node_id for selection in ordered_selections)
|
||||
rows: list[sqlite3.Row] = []
|
||||
for position in range(0, len(selected_node_ids), _SQLITE_PARAMETER_CHUNK):
|
||||
node_id_chunk = selected_node_ids[position : position + _SQLITE_PARAMETER_CHUNK]
|
||||
placeholders = ",".join("?" for _ in node_id_chunk)
|
||||
rows.extend(
|
||||
snapshot.connection.execute(
|
||||
f"SELECT * FROM nodes WHERE node_id IN ({placeholders}) ORDER BY node_id",
|
||||
node_id_chunk,
|
||||
).fetchall()
|
||||
)
|
||||
nodes = {cast(str, row["node_id"]): _row_to_node(row) for row in rows}
|
||||
evidence: list[CapsuleEvidenceV1] = []
|
||||
omissions: list[CapsuleOmissionV1] = []
|
||||
used_tokens = 0
|
||||
returned_categories: set[str] = set()
|
||||
for selection in ordered_selections:
|
||||
node = nodes[selection.node_id]
|
||||
item = capsule_evidence(
|
||||
role=cast(Literal["focus", "related"], selection.role),
|
||||
reason_code=cast(
|
||||
Literal["exact_focus", "lexical_focus", "relationship_path"],
|
||||
selection.reason_code,
|
||||
),
|
||||
node=node,
|
||||
depth=selection.depth,
|
||||
relationship_path=selection.relationship_path,
|
||||
relationship_reasons=tuple(
|
||||
sorted(
|
||||
relationship_reasons.get(selection.node_id, set()),
|
||||
key=lambda relationship: (
|
||||
relationship.source_id,
|
||||
relationship.relation,
|
||||
relationship.target_id,
|
||||
relationship.direction,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
if used_tokens + item.estimated_tokens > plan.max_tokens:
|
||||
omissions.append(
|
||||
CapsuleOmissionV1(
|
||||
code="token_budget",
|
||||
subject=node.node_id,
|
||||
detail_hash=canonical_hash(
|
||||
{
|
||||
"node_id": node.node_id,
|
||||
"content_hash": node.content_hash,
|
||||
"estimated_tokens": item.estimated_tokens,
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
evidence.append(item)
|
||||
used_tokens += item.estimated_tokens
|
||||
returned_categories.update(
|
||||
relationship.category for relationship in item.relationship_reasons
|
||||
)
|
||||
if first_omitted_node is not None:
|
||||
omissions.append(
|
||||
CapsuleOmissionV1(
|
||||
code="result_limit",
|
||||
subject=first_omitted_node,
|
||||
detail_hash=canonical_hash(
|
||||
{
|
||||
"node_id": first_omitted_node,
|
||||
"max_evidence": plan.max_evidence,
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
if edge_examination_limit_reached:
|
||||
omissions.append(
|
||||
CapsuleOmissionV1(
|
||||
code="edge_examination_limit",
|
||||
subject=focus_node_id,
|
||||
detail_hash=canonical_hash(
|
||||
{
|
||||
"focus_node_id": focus_node_id,
|
||||
"examined_edge_count": examined_edge_count,
|
||||
"max_candidate_edges": plan.max_candidate_edges,
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
ordered_unclassified_relations = tuple(sorted(unclassified_relations))
|
||||
bounded_unclassified_relations = ordered_unclassified_relations[: plan.max_evidence]
|
||||
if len(ordered_unclassified_relations) > len(bounded_unclassified_relations):
|
||||
omissions.append(
|
||||
CapsuleOmissionV1(
|
||||
code="unclassified_relation_limit",
|
||||
subject=focus_node_id,
|
||||
detail_hash=canonical_hash(
|
||||
{
|
||||
"unclassified_relation_count": len(ordered_unclassified_relations),
|
||||
"returned_count": len(bounded_unclassified_relations),
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
declared_categories = {
|
||||
relation_category(relation) for relation in self.project.descriptor.allowed_relations
|
||||
}
|
||||
gaps: list[EvidenceGapV1] = []
|
||||
incomplete = traversal_incomplete or bool(omissions)
|
||||
for requirement in plan.requirements:
|
||||
category = requirement.category
|
||||
if category not in declared_categories:
|
||||
gaps.append(
|
||||
EvidenceGapV1(
|
||||
code="category_not_declared",
|
||||
requirement_id=requirement.requirement_id,
|
||||
category=category,
|
||||
state="missing",
|
||||
check_complete=True,
|
||||
detail=(
|
||||
"No declared project relation maps to this required plan category."
|
||||
),
|
||||
)
|
||||
)
|
||||
elif category in returned_categories:
|
||||
continue
|
||||
elif incomplete:
|
||||
gaps.append(
|
||||
EvidenceGapV1(
|
||||
code="evidence_incomplete",
|
||||
requirement_id=requirement.requirement_id,
|
||||
category=category,
|
||||
state="incomplete",
|
||||
check_complete=False,
|
||||
detail=(
|
||||
"A bounded work, result, or token limit prevented a complete "
|
||||
"returned-evidence proof."
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
gaps.append(
|
||||
EvidenceGapV1(
|
||||
code="no_selected_evidence",
|
||||
requirement_id=requirement.requirement_id,
|
||||
category=category,
|
||||
state="missing",
|
||||
check_complete=True,
|
||||
detail=(
|
||||
"The complete bounded check found no selected graph evidence in "
|
||||
"this category."
|
||||
),
|
||||
)
|
||||
)
|
||||
for relation in bounded_unclassified_relations:
|
||||
gaps.append(
|
||||
EvidenceGapV1(
|
||||
code="unclassified_relation",
|
||||
requirement_id=f"relation.{relation}",
|
||||
category="unclassified",
|
||||
state="limitation",
|
||||
check_complete=True,
|
||||
detail="The project relation is preserved without inferred task semantics.",
|
||||
)
|
||||
)
|
||||
gaps.sort(
|
||||
key=lambda gap: (
|
||||
gap.requirement_id,
|
||||
gap.code,
|
||||
"" if gap.category is None else gap.category,
|
||||
)
|
||||
)
|
||||
return finalize_capsule(
|
||||
state="incomplete" if incomplete else "complete",
|
||||
task_kind=plan.task_kind,
|
||||
generation=generation,
|
||||
plan=plan,
|
||||
focus_state="resolved",
|
||||
focus_node_id=focus_node_id,
|
||||
focus_candidate_count=len(focus_rows),
|
||||
evidence=tuple(evidence),
|
||||
gaps=tuple(gaps),
|
||||
omissions=tuple(omissions),
|
||||
selected_count=len(selections),
|
||||
examined_edge_count=examined_edge_count,
|
||||
estimated_tokens=used_tokens,
|
||||
unclassified_relations=bounded_unclassified_relations,
|
||||
)
|
||||
|
||||
def get_node(self, node_id: str) -> dict[str, object]:
|
||||
with self._read_snapshot() as snapshot:
|
||||
row = snapshot.connection.execute(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from .pagination import canonical_hash, decode_cursor, page_limit, page_receipt
|
|||
from .policy import CapabilityMode, capability_mode, compose_effective_policy
|
||||
from .project import Project, project_root_fingerprint
|
||||
from .rendering import RenderService
|
||||
from .retrieval import MAX_TASK_EVIDENCE, TaskKind, build_retrieval_plan
|
||||
from .telemetry import request, stage
|
||||
from .viewer_manager import ViewerManagerClient
|
||||
|
||||
|
|
@ -43,6 +44,7 @@ READ_TOOLS = (
|
|||
"docforge_dependencies",
|
||||
"docforge_impact",
|
||||
"docforge_get_context",
|
||||
"docforge_get_task_context",
|
||||
"docforge_validate_project",
|
||||
"docforge_render_status",
|
||||
"docforge_visualize",
|
||||
|
|
@ -164,6 +166,7 @@ class DocForgeService:
|
|||
)
|
||||
self.visualization = ViewerManagerClient(self.index)
|
||||
self.context_provider = context_provider
|
||||
self.task_context_available = context_provider is compile_context
|
||||
self.binding_metadata = dict(binding_metadata or {})
|
||||
self.no_ast = self.policy.no_ast
|
||||
self.diagnostics = diagnostics
|
||||
|
|
@ -199,6 +202,14 @@ class DocForgeService:
|
|||
"enabled": True,
|
||||
"tools": [tool for tool in READ_TOOLS if tool in self.tool_surface],
|
||||
},
|
||||
"task_context": {
|
||||
"enabled": self.task_context_available,
|
||||
"reason": (
|
||||
None
|
||||
if self.task_context_available
|
||||
else "custom_context_policy_not_supported_by_task_context_v1"
|
||||
),
|
||||
},
|
||||
"proposal": {
|
||||
"surface_enabled": any(tool in self.tool_surface for tool in PROPOSAL_TOOLS),
|
||||
"mutation_access": proposal_access,
|
||||
|
|
@ -558,7 +569,11 @@ class DocForgeService:
|
|||
"adapter_policy": self.adapter_policy(),
|
||||
}
|
||||
recommended_workflow = [
|
||||
"docforge_get_context or targeted read tools",
|
||||
(
|
||||
"docforge_get_task_context, docforge_get_context, or targeted read tools"
|
||||
if self.task_context_available
|
||||
else "docforge_get_context or targeted read tools"
|
||||
),
|
||||
"make and verify one coherent implementation slice",
|
||||
"docforge_sync",
|
||||
]
|
||||
|
|
@ -582,8 +597,17 @@ class DocForgeService:
|
|||
"compiler-AST, or function-Logic extraction"
|
||||
),
|
||||
)
|
||||
if descriptor.profiles:
|
||||
if self.task_context_available:
|
||||
recommended_first_operation: dict[str, object] = {
|
||||
"tool": "docforge_get_task_context",
|
||||
"arguments": {
|
||||
"task_kind": "implementation",
|
||||
"task": "<describe the current task>",
|
||||
},
|
||||
"reason": "Begin with one bounded task-shaped context capsule.",
|
||||
}
|
||||
elif descriptor.profiles:
|
||||
recommended_first_operation = {
|
||||
"tool": "docforge_get_context",
|
||||
"arguments": {"profile": descriptor.profiles[0].profile_id},
|
||||
"reason": "Begin with one configured bounded context profile.",
|
||||
|
|
@ -831,6 +855,210 @@ class DocForgeService:
|
|||
operation_name="mcp.context",
|
||||
)
|
||||
|
||||
def task_context(
|
||||
self,
|
||||
task_kind: TaskKind,
|
||||
task: str,
|
||||
*,
|
||||
focus_node_id: str | None = None,
|
||||
budget: int | None = None,
|
||||
limit: int | None = None,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return one task-shaped capsule from a single immutable graph generation."""
|
||||
|
||||
if not self.task_context_available:
|
||||
|
||||
def unavailable() -> dict[str, object]:
|
||||
raise DocForgeError(
|
||||
"task_context_unavailable",
|
||||
(
|
||||
"This binding uses a custom context provider; "
|
||||
"core task planning is unavailable"
|
||||
),
|
||||
)
|
||||
|
||||
return self.invoke(
|
||||
unavailable,
|
||||
synchronize=False,
|
||||
load_error_identity=False,
|
||||
operation_name="mcp.task_context",
|
||||
)
|
||||
|
||||
def operation() -> dict[str, object]:
|
||||
maximum_evidence = min(
|
||||
self.project.descriptor.limits.max_results,
|
||||
MAX_TASK_EVIDENCE,
|
||||
)
|
||||
selected_limit = page_limit(
|
||||
limit,
|
||||
default=min(20, maximum_evidence),
|
||||
maximum=maximum_evidence,
|
||||
)
|
||||
plan = build_retrieval_plan(
|
||||
self.project.descriptor,
|
||||
task_kind=task_kind,
|
||||
task=task,
|
||||
focus_node_id=focus_node_id,
|
||||
budget=budget,
|
||||
limit=maximum_evidence,
|
||||
effective_policy=self.policy.as_dict(),
|
||||
)
|
||||
result = self.index.task_context(plan)
|
||||
return self._page_task_context_result(
|
||||
result,
|
||||
selected_limit=selected_limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
|
||||
return self.invoke(operation, operation_name="mcp.task_context")
|
||||
|
||||
def _page_task_context_result(
|
||||
self,
|
||||
result: dict[str, object],
|
||||
*,
|
||||
selected_limit: int,
|
||||
cursor: str | None,
|
||||
) -> dict[str, object]:
|
||||
capsule_value = result.get("capsule")
|
||||
if not isinstance(capsule_value, Mapping):
|
||||
raise DocForgeError(
|
||||
"invalid_task_context_result",
|
||||
"Task context did not return a versioned capsule",
|
||||
)
|
||||
capsule = dict(cast(Mapping[str, object], capsule_value))
|
||||
plan_value = capsule.get("plan")
|
||||
generation_value = capsule.get("generation")
|
||||
evidence_value = capsule.get("evidence")
|
||||
omissions_value = capsule.get("omissions")
|
||||
gaps_value = capsule.get("gaps")
|
||||
if (
|
||||
capsule.get("schema_version") != 1
|
||||
or not isinstance(plan_value, Mapping)
|
||||
or not isinstance(generation_value, Mapping)
|
||||
or not isinstance(evidence_value, list)
|
||||
or not isinstance(omissions_value, list)
|
||||
or not isinstance(gaps_value, list)
|
||||
or not isinstance(capsule.get("collection_hash"), str)
|
||||
or not isinstance(capsule.get("capsule_hash"), str)
|
||||
):
|
||||
raise DocForgeError(
|
||||
"invalid_task_context_result",
|
||||
"Task context capsule is malformed",
|
||||
)
|
||||
plan_payload = cast(Mapping[str, object], plan_value)
|
||||
generation = cast(Mapping[str, object], generation_value)
|
||||
evidence = cast(list[object], evidence_value)
|
||||
omissions = cast(list[object], omissions_value)
|
||||
gaps = cast(list[object], gaps_value)
|
||||
binding = {
|
||||
"project_id": result.get("project_id"),
|
||||
"project_root_fingerprint": result.get("project_root_fingerprint"),
|
||||
"adapter": result.get("adapter"),
|
||||
"revision": result.get("revision"),
|
||||
"source_hash": result.get("source_hash"),
|
||||
"index_schema_version": generation.get("index_schema_version"),
|
||||
"effective_policy_hash": plan_payload.get("effective_policy_hash"),
|
||||
"request_hash": plan_payload.get("request_hash"),
|
||||
"plan_hash": plan_payload.get("plan_hash"),
|
||||
"collection_hash": capsule["collection_hash"],
|
||||
"capsule_hash": capsule["capsule_hash"],
|
||||
}
|
||||
items = [
|
||||
*(("evidence", item) for item in evidence),
|
||||
*(("omission", item) for item in omissions),
|
||||
]
|
||||
position = decode_cursor(
|
||||
cursor,
|
||||
kind="task-context.items",
|
||||
binding=binding,
|
||||
total_count=len(items),
|
||||
)
|
||||
page_evidence: list[object] = []
|
||||
page_omissions: list[object] = []
|
||||
consumed = 0
|
||||
response_limited = False
|
||||
maximum = self.project.descriptor.limits.max_tool_output_chars
|
||||
|
||||
def page_result() -> dict[str, object]:
|
||||
pagination = page_receipt(
|
||||
kind="task-context.items",
|
||||
binding=binding,
|
||||
position=position,
|
||||
count=consumed,
|
||||
limit=selected_limit,
|
||||
total_count=len(items),
|
||||
)
|
||||
page_state = "incomplete" if response_limited else capsule.get("state")
|
||||
page_summary = {
|
||||
**cast(dict[str, object], capsule.get("summary", {})),
|
||||
"page_evidence_count": len(page_evidence),
|
||||
"page_omission_count": len(page_omissions),
|
||||
"page_item_count": consumed,
|
||||
}
|
||||
page_hash = canonical_hash(
|
||||
{
|
||||
"capsule_hash": capsule["capsule_hash"],
|
||||
"position": position,
|
||||
"page_state": page_state,
|
||||
"pagination": pagination,
|
||||
"summary": page_summary,
|
||||
"evidence": page_evidence,
|
||||
"gaps": gaps,
|
||||
"omissions": page_omissions,
|
||||
}
|
||||
)
|
||||
page_capsule = {
|
||||
**capsule,
|
||||
"evidence": page_evidence,
|
||||
"omissions": page_omissions,
|
||||
"page_state": page_state,
|
||||
"page_hash": page_hash,
|
||||
"pagination": pagination,
|
||||
"summary": page_summary,
|
||||
}
|
||||
return {
|
||||
**result,
|
||||
"capsule": page_capsule,
|
||||
"next_cursor": pagination["next_cursor"],
|
||||
"pagination": pagination,
|
||||
}
|
||||
|
||||
for kind, item in items[position:]:
|
||||
if consumed >= selected_limit:
|
||||
break
|
||||
destination = page_evidence if kind == "evidence" else page_omissions
|
||||
destination.append(item)
|
||||
consumed += 1
|
||||
decorated = {
|
||||
**page_result(),
|
||||
"server_version": SERVER_VERSION,
|
||||
"content_warning": CONTENT_WARNING,
|
||||
"staleness": "current",
|
||||
}
|
||||
if self._encoded_length(decorated) <= maximum:
|
||||
continue
|
||||
destination.pop()
|
||||
consumed -= 1
|
||||
response_limited = True
|
||||
if consumed == 0:
|
||||
subject = "unknown"
|
||||
if isinstance(item, Mapping):
|
||||
item_payload = cast(Mapping[str, object], item)
|
||||
candidate = item_payload.get("node_id") or item_payload.get("subject")
|
||||
if isinstance(candidate, str) and candidate:
|
||||
subject = candidate[:256]
|
||||
page_omissions.append(
|
||||
{
|
||||
"code": "response_limit",
|
||||
"subject": subject,
|
||||
"detail_hash": canonical_hash(cast(object, item)),
|
||||
}
|
||||
)
|
||||
consumed = 1
|
||||
break
|
||||
return page_result()
|
||||
|
||||
def _page_context_result(
|
||||
self,
|
||||
result: dict[str, object],
|
||||
|
|
@ -1151,6 +1379,26 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
|
|||
|
||||
return service.context(profile, budget, limit=limit, cursor=cursor)
|
||||
|
||||
@server.tool(name="docforge_get_task_context")
|
||||
def get_task_context(
|
||||
task_kind: TaskKind,
|
||||
task: str,
|
||||
focus_node_id: str | None = None,
|
||||
budget: int | None = None,
|
||||
limit: int | None = None,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return one bounded task-shaped context capsule with explicit evidence gaps."""
|
||||
|
||||
return service.task_context(
|
||||
task_kind,
|
||||
task,
|
||||
focus_node_id=focus_node_id,
|
||||
budget=budget,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
|
||||
@server.tool(name="docforge_validate_project")
|
||||
def validate_project() -> dict[str, Any]:
|
||||
"""Validate current canonical sources and graph without writing any project file."""
|
||||
|
|
@ -1201,6 +1449,7 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
|
|||
dependencies,
|
||||
impact,
|
||||
get_context,
|
||||
get_task_context,
|
||||
validate_project,
|
||||
render_status,
|
||||
visualize,
|
||||
|
|
|
|||
671
src/docforge/retrieval.py
Normal file
671
src/docforge/retrieval.py
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
"""Versioned task-shaped retrieval plans and immutable context capsules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Literal, cast
|
||||
|
||||
from .errors import DocForgeError
|
||||
from .models import Edge, Node, ProjectDescriptor
|
||||
from .pagination import canonical_hash
|
||||
|
||||
TaskKind = Literal[
|
||||
"change",
|
||||
"implementation",
|
||||
"failure",
|
||||
"ownership",
|
||||
"test",
|
||||
"operation",
|
||||
"release",
|
||||
]
|
||||
TASK_KINDS: tuple[TaskKind, ...] = (
|
||||
"change",
|
||||
"implementation",
|
||||
"failure",
|
||||
"ownership",
|
||||
"test",
|
||||
"operation",
|
||||
"release",
|
||||
)
|
||||
|
||||
RelationCategory = Literal[
|
||||
"structure",
|
||||
"implementation",
|
||||
"dependency",
|
||||
"execution",
|
||||
"data",
|
||||
"evidence",
|
||||
"context",
|
||||
"unclassified",
|
||||
]
|
||||
BASE_RELATION_CATEGORIES: tuple[RelationCategory, ...] = (
|
||||
"structure",
|
||||
"implementation",
|
||||
"dependency",
|
||||
"execution",
|
||||
"data",
|
||||
"evidence",
|
||||
"context",
|
||||
)
|
||||
|
||||
RELATION_CATEGORIES: dict[RelationCategory, tuple[str, ...]] = {
|
||||
"structure": ("contains", "defined_in", "defines", "owns"),
|
||||
"implementation": (
|
||||
"implemented_by",
|
||||
"implements",
|
||||
"inherits",
|
||||
"inherits_from",
|
||||
),
|
||||
"dependency": ("depends_on", "imports"),
|
||||
"execution": ("activates", "calls", "dispatches_to", "launches"),
|
||||
"data": ("reads", "writes"),
|
||||
"evidence": ("documents", "governs", "proves", "tested_by", "verifies"),
|
||||
"context": ("relates_to",),
|
||||
"unclassified": (),
|
||||
}
|
||||
|
||||
TASK_REQUIREMENTS: dict[TaskKind, tuple[RelationCategory, ...]] = {
|
||||
"change": ("dependency",),
|
||||
"implementation": ("implementation",),
|
||||
"failure": ("execution",),
|
||||
"ownership": ("structure",),
|
||||
"test": ("evidence",),
|
||||
"operation": ("execution",),
|
||||
"release": ("evidence",),
|
||||
}
|
||||
|
||||
PLANNER_ID = "docforge.core.task-context"
|
||||
PLANNER_VERSION = 1
|
||||
MAX_TASK_EVIDENCE = 1_000
|
||||
MAX_TASK_CANDIDATE_EDGES = 100_000
|
||||
MAX_TASK_QUERY_CHARS = 10_000
|
||||
PROVENANCE_LIMITATIONS = (
|
||||
"evidence_type_unavailable",
|
||||
"extractor_identity_unavailable",
|
||||
"relationship_provenance_unavailable",
|
||||
"observation_time_unavailable",
|
||||
)
|
||||
|
||||
_RELATION_TO_CATEGORY = {
|
||||
relation: category
|
||||
for category, relations in RELATION_CATEGORIES.items()
|
||||
for relation in relations
|
||||
}
|
||||
|
||||
|
||||
def relation_category(relation: str) -> RelationCategory:
|
||||
"""Classify only versioned known aliases; preserve every other relation."""
|
||||
|
||||
return cast(RelationCategory, _RELATION_TO_CATEGORY.get(relation, "unclassified"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetrievalStepV1:
|
||||
step_id: str
|
||||
operation: Literal["exact", "search", "outgoing", "incoming", "metadata"]
|
||||
relation_scope: Literal["none", "project_allowed"]
|
||||
relation_set_hash: str | None
|
||||
direction: Literal["none", "outgoing", "incoming"]
|
||||
depth: int
|
||||
limit: int
|
||||
required: bool
|
||||
evidence_role: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"step_id": self.step_id,
|
||||
"operation": self.operation,
|
||||
"relation_scope": self.relation_scope,
|
||||
"relation_set_hash": self.relation_set_hash,
|
||||
"direction": self.direction,
|
||||
"depth": self.depth,
|
||||
"limit": self.limit,
|
||||
"required": self.required,
|
||||
"evidence_role": self.evidence_role,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetrievalRequirementV1:
|
||||
requirement_id: str
|
||||
category: RelationCategory
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"requirement_id": self.requirement_id,
|
||||
"check": "selected_relation_category",
|
||||
"category": self.category,
|
||||
"required": True,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetrievalPlanV1:
|
||||
schema_version: Literal[1]
|
||||
planner_id: str
|
||||
planner_version: int
|
||||
task_kind: TaskKind
|
||||
task_query: str
|
||||
focus_node_id: str | None
|
||||
request_hash: str
|
||||
effective_policy_hash: str
|
||||
max_evidence: int
|
||||
max_tokens: int
|
||||
max_depth: int
|
||||
max_candidate_edges: int
|
||||
category_order: tuple[RelationCategory, ...]
|
||||
steps: tuple[RetrievalStepV1, ...]
|
||||
requirements: tuple[RetrievalRequirementV1, ...]
|
||||
plan_hash: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return self.payload(include_hash=True)
|
||||
|
||||
def payload(self, *, include_hash: bool) -> dict[str, object]:
|
||||
result: dict[str, object] = {
|
||||
"schema_version": self.schema_version,
|
||||
"planner": {
|
||||
"id": self.planner_id,
|
||||
"version": self.planner_version,
|
||||
},
|
||||
"task_kind": self.task_kind,
|
||||
"request_hash": self.request_hash,
|
||||
"effective_policy_hash": self.effective_policy_hash,
|
||||
"focus_node_id": self.focus_node_id,
|
||||
"limits": {
|
||||
"max_evidence": self.max_evidence,
|
||||
"max_tokens": self.max_tokens,
|
||||
"max_depth": self.max_depth,
|
||||
"max_candidate_edges": self.max_candidate_edges,
|
||||
},
|
||||
"category_order": list(self.category_order),
|
||||
"steps": [step.as_dict() for step in self.steps],
|
||||
"requirements": [requirement.as_dict() for requirement in self.requirements],
|
||||
}
|
||||
if include_hash:
|
||||
result["plan_hash"] = self.plan_hash
|
||||
return result
|
||||
|
||||
|
||||
def build_retrieval_plan(
|
||||
descriptor: ProjectDescriptor,
|
||||
*,
|
||||
task_kind: str,
|
||||
task: str,
|
||||
focus_node_id: str | None,
|
||||
budget: int | None,
|
||||
limit: int | None,
|
||||
effective_policy: dict[str, object],
|
||||
) -> RetrievalPlanV1:
|
||||
"""Derive one fixed plan from bounded inputs rather than accepting caller operations."""
|
||||
|
||||
return _build_retrieval_plan(
|
||||
descriptor,
|
||||
task_kind=task_kind,
|
||||
task=task,
|
||||
focus_node_id=focus_node_id,
|
||||
budget=budget,
|
||||
limit=limit,
|
||||
effective_policy_hash=canonical_hash(effective_policy),
|
||||
)
|
||||
|
||||
|
||||
def validate_retrieval_plan(
|
||||
plan: RetrievalPlanV1,
|
||||
descriptor: ProjectDescriptor,
|
||||
) -> RetrievalPlanV1:
|
||||
"""Reject forged, stale-shape, or internally inconsistent public plan objects."""
|
||||
|
||||
try:
|
||||
expected = _build_retrieval_plan(
|
||||
descriptor,
|
||||
task_kind=plan.task_kind,
|
||||
task=plan.task_query,
|
||||
focus_node_id=plan.focus_node_id,
|
||||
budget=plan.max_tokens,
|
||||
limit=plan.max_evidence,
|
||||
effective_policy_hash=plan.effective_policy_hash,
|
||||
)
|
||||
except (AttributeError, TypeError, DocForgeError) as error:
|
||||
raise DocForgeError(
|
||||
"invalid_retrieval_plan",
|
||||
"Task retrieval plan is malformed or outside the fixed version-1 contract",
|
||||
) from error
|
||||
if plan != expected:
|
||||
raise DocForgeError(
|
||||
"invalid_retrieval_plan",
|
||||
"Task retrieval plan does not match its fixed version-1 derivation",
|
||||
)
|
||||
return plan
|
||||
|
||||
|
||||
def _build_retrieval_plan(
|
||||
descriptor: ProjectDescriptor,
|
||||
*,
|
||||
task_kind: str,
|
||||
task: str,
|
||||
focus_node_id: str | None,
|
||||
budget: int | None,
|
||||
limit: int | None,
|
||||
effective_policy_hash: str,
|
||||
) -> RetrievalPlanV1:
|
||||
if task_kind not in TASK_KINDS:
|
||||
raise DocForgeError(
|
||||
"invalid_task_kind",
|
||||
"Task context kind is unsupported",
|
||||
task_kind=task_kind,
|
||||
allowed=list(TASK_KINDS),
|
||||
)
|
||||
selected_kind: TaskKind = task_kind # type: ignore[assignment]
|
||||
normalized_task = task.strip()
|
||||
if not normalized_task or len(normalized_task) > min(
|
||||
descriptor.limits.max_query_chars,
|
||||
MAX_TASK_QUERY_CHARS,
|
||||
):
|
||||
raise DocForgeError(
|
||||
"invalid_task_focus",
|
||||
"Task description is empty or exceeds the configured query limit",
|
||||
)
|
||||
if focus_node_id is not None and (not focus_node_id or len(focus_node_id) > 256):
|
||||
raise DocForgeError("invalid_task_focus", "Task focus node ID is invalid")
|
||||
selected_budget = _bounded_value(
|
||||
budget,
|
||||
default=min(8_000, descriptor.limits.max_context_tokens),
|
||||
maximum=descriptor.limits.max_context_tokens,
|
||||
code="invalid_budget",
|
||||
)
|
||||
selected_limit = _bounded_value(
|
||||
limit,
|
||||
default=min(20, descriptor.limits.max_results),
|
||||
maximum=min(descriptor.limits.max_results, MAX_TASK_EVIDENCE),
|
||||
code="invalid_limit",
|
||||
)
|
||||
if not _is_sha256(effective_policy_hash):
|
||||
raise DocForgeError(
|
||||
"invalid_retrieval_plan",
|
||||
"Effective policy identity is not a SHA-256 value",
|
||||
)
|
||||
selected_depth = min(2, descriptor.limits.max_traversal_depth)
|
||||
requirements = tuple(
|
||||
RetrievalRequirementV1(
|
||||
requirement_id=f"{selected_kind}.{category}",
|
||||
category=category,
|
||||
)
|
||||
for category in TASK_REQUIREMENTS[selected_kind]
|
||||
)
|
||||
category_order: tuple[RelationCategory, ...] = (
|
||||
*TASK_REQUIREMENTS[selected_kind],
|
||||
*(
|
||||
category
|
||||
for category in BASE_RELATION_CATEGORIES
|
||||
if category not in TASK_REQUIREMENTS[selected_kind]
|
||||
),
|
||||
"unclassified",
|
||||
)
|
||||
relation_set_hash = canonical_hash(sorted(descriptor.allowed_relations))
|
||||
focus_operation: Literal["exact", "search"] = "exact" if focus_node_id else "search"
|
||||
steps = (
|
||||
RetrievalStepV1(
|
||||
step_id="focus",
|
||||
operation=focus_operation,
|
||||
relation_scope="none",
|
||||
relation_set_hash=None,
|
||||
direction="none",
|
||||
depth=0,
|
||||
limit=1,
|
||||
required=True,
|
||||
evidence_role="focus",
|
||||
),
|
||||
RetrievalStepV1(
|
||||
step_id="outgoing",
|
||||
operation="outgoing",
|
||||
relation_scope="project_allowed",
|
||||
relation_set_hash=relation_set_hash,
|
||||
direction="outgoing",
|
||||
depth=selected_depth,
|
||||
limit=selected_limit,
|
||||
required=False,
|
||||
evidence_role="related",
|
||||
),
|
||||
RetrievalStepV1(
|
||||
step_id="incoming",
|
||||
operation="incoming",
|
||||
relation_scope="project_allowed",
|
||||
relation_set_hash=relation_set_hash,
|
||||
direction="incoming",
|
||||
depth=selected_depth,
|
||||
limit=selected_limit,
|
||||
required=False,
|
||||
evidence_role="related",
|
||||
),
|
||||
RetrievalStepV1(
|
||||
step_id="metadata",
|
||||
operation="metadata",
|
||||
relation_scope="none",
|
||||
relation_set_hash=None,
|
||||
direction="none",
|
||||
depth=0,
|
||||
limit=selected_limit,
|
||||
required=True,
|
||||
evidence_role="provenance",
|
||||
),
|
||||
)
|
||||
request_hash = canonical_hash(
|
||||
{
|
||||
"task_kind": selected_kind,
|
||||
"task": normalized_task,
|
||||
"focus_node_id": focus_node_id,
|
||||
"budget": selected_budget,
|
||||
"limit": selected_limit,
|
||||
}
|
||||
)
|
||||
placeholder = RetrievalPlanV1(
|
||||
schema_version=1,
|
||||
planner_id=PLANNER_ID,
|
||||
planner_version=PLANNER_VERSION,
|
||||
task_kind=selected_kind,
|
||||
task_query=normalized_task,
|
||||
focus_node_id=focus_node_id,
|
||||
request_hash=request_hash,
|
||||
effective_policy_hash=effective_policy_hash,
|
||||
max_evidence=selected_limit,
|
||||
max_tokens=selected_budget,
|
||||
max_depth=selected_depth,
|
||||
max_candidate_edges=min(
|
||||
(selected_limit + 1) ** 2,
|
||||
MAX_TASK_CANDIDATE_EDGES,
|
||||
),
|
||||
category_order=category_order,
|
||||
steps=steps,
|
||||
requirements=requirements,
|
||||
plan_hash="",
|
||||
)
|
||||
return replace(
|
||||
placeholder,
|
||||
plan_hash=canonical_hash(placeholder.payload(include_hash=False)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapsuleRelationshipV1:
|
||||
source_id: str
|
||||
relation: str
|
||||
target_id: str
|
||||
direction: Literal["outgoing", "incoming"]
|
||||
category: RelationCategory
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"source_id": self.source_id,
|
||||
"relation": self.relation,
|
||||
"target_id": self.target_id,
|
||||
"direction": self.direction,
|
||||
"category": self.category,
|
||||
"provenance": "validated_graph_edge_without_source_provenance",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapsuleEvidenceV1:
|
||||
evidence_hash: str
|
||||
role: Literal["focus", "related"]
|
||||
reason_code: Literal["exact_focus", "lexical_focus", "relationship_path"]
|
||||
node: Node
|
||||
depth: int
|
||||
relationship_path: tuple[CapsuleRelationshipV1, ...]
|
||||
relationship_reasons: tuple[CapsuleRelationshipV1, ...]
|
||||
estimated_tokens: int
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
result = self.payload()
|
||||
return {"evidence_hash": self.evidence_hash, **result}
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
result: dict[str, object] = {
|
||||
"role": self.role,
|
||||
"reason_code": self.reason_code,
|
||||
"node_id": self.node.node_id,
|
||||
"title": self.node.title,
|
||||
"family": self.node.family,
|
||||
"authority": self.node.authority,
|
||||
"status": self.node.status,
|
||||
"tags": list(self.node.tags),
|
||||
"summary": self.node.summary,
|
||||
"text": _node_text(self.node),
|
||||
"estimated_tokens": self.estimated_tokens,
|
||||
"source": {
|
||||
"path": self.node.source_path,
|
||||
"anchor": self.node.source_anchor,
|
||||
"content_hash": self.node.content_hash,
|
||||
},
|
||||
"depth": self.depth,
|
||||
"relationship_path": [
|
||||
relationship.as_dict() for relationship in self.relationship_path
|
||||
],
|
||||
"relationship_reasons": [
|
||||
relationship.as_dict() for relationship in self.relationship_reasons
|
||||
],
|
||||
"provenance_limitations": list(PROVENANCE_LIMITATIONS),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def capsule_evidence(
|
||||
*,
|
||||
role: Literal["focus", "related"],
|
||||
reason_code: Literal["exact_focus", "lexical_focus", "relationship_path"],
|
||||
node: Node,
|
||||
depth: int,
|
||||
relationship_path: tuple[CapsuleRelationshipV1, ...],
|
||||
relationship_reasons: tuple[CapsuleRelationshipV1, ...],
|
||||
) -> CapsuleEvidenceV1:
|
||||
tokens = estimate_tokens(_node_text(node))
|
||||
placeholder = CapsuleEvidenceV1(
|
||||
evidence_hash="",
|
||||
role=role,
|
||||
reason_code=reason_code,
|
||||
node=node,
|
||||
depth=depth,
|
||||
relationship_path=relationship_path,
|
||||
relationship_reasons=relationship_reasons,
|
||||
estimated_tokens=tokens,
|
||||
)
|
||||
return replace(
|
||||
placeholder,
|
||||
evidence_hash=canonical_hash(placeholder.payload()),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenceGapV1:
|
||||
code: Literal[
|
||||
"focus_not_found",
|
||||
"focus_ambiguous",
|
||||
"category_not_declared",
|
||||
"no_selected_evidence",
|
||||
"evidence_incomplete",
|
||||
"unclassified_relation",
|
||||
]
|
||||
requirement_id: str
|
||||
category: RelationCategory | None
|
||||
state: Literal["missing", "incomplete", "blocked", "limitation"]
|
||||
check_complete: bool
|
||||
detail: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"requirement_id": self.requirement_id,
|
||||
"category": self.category,
|
||||
"state": self.state,
|
||||
"check_complete": self.check_complete,
|
||||
"detail": self.detail,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapsuleOmissionV1:
|
||||
code: Literal[
|
||||
"result_limit",
|
||||
"token_budget",
|
||||
"response_limit",
|
||||
"edge_examination_limit",
|
||||
"unclassified_relation_limit",
|
||||
]
|
||||
subject: str
|
||||
detail_hash: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"subject": self.subject,
|
||||
"detail_hash": self.detail_hash,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextCapsuleV1:
|
||||
schema_version: Literal[1]
|
||||
state: Literal["complete", "incomplete", "blocked"]
|
||||
task_kind: TaskKind
|
||||
generation: tuple[tuple[str, object], ...]
|
||||
plan: RetrievalPlanV1
|
||||
focus_state: Literal["resolved", "not_found", "ambiguous"]
|
||||
focus_node_id: str | None
|
||||
focus_candidate_count: int
|
||||
evidence: tuple[CapsuleEvidenceV1, ...]
|
||||
gaps: tuple[EvidenceGapV1, ...]
|
||||
omissions: tuple[CapsuleOmissionV1, ...]
|
||||
selected_count: int
|
||||
examined_edge_count: int
|
||||
estimated_tokens: int
|
||||
unclassified_relations: tuple[str, ...]
|
||||
collection_hash: str
|
||||
capsule_hash: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return self.payload(include_hashes=True)
|
||||
|
||||
def payload(self, *, include_hashes: bool) -> dict[str, object]:
|
||||
evidence = [item.as_dict() for item in self.evidence]
|
||||
gaps = [gap.as_dict() for gap in self.gaps]
|
||||
omissions = [omission.as_dict() for omission in self.omissions]
|
||||
result: dict[str, object] = {
|
||||
"schema_version": self.schema_version,
|
||||
"state": self.state,
|
||||
"task_kind": self.task_kind,
|
||||
"generation": dict(self.generation),
|
||||
"plan": self.plan.as_dict(),
|
||||
"focus": {
|
||||
"state": self.focus_state,
|
||||
"node_id": self.focus_node_id,
|
||||
"candidate_count": self.focus_candidate_count,
|
||||
},
|
||||
"evidence": evidence,
|
||||
"gaps": gaps,
|
||||
"omissions": omissions,
|
||||
"summary": {
|
||||
"evidence_count": len(evidence),
|
||||
"gap_count": len(gaps),
|
||||
"omission_count": len(omissions),
|
||||
"selected_count": self.selected_count,
|
||||
"examined_edge_count": self.examined_edge_count,
|
||||
"estimated_tokens": self.estimated_tokens,
|
||||
"unclassified_relations": list(self.unclassified_relations),
|
||||
},
|
||||
}
|
||||
if include_hashes:
|
||||
result["collection_hash"] = self.collection_hash
|
||||
result["capsule_hash"] = self.capsule_hash
|
||||
return result
|
||||
|
||||
|
||||
def finalize_capsule(
|
||||
*,
|
||||
state: Literal["complete", "incomplete", "blocked"],
|
||||
task_kind: TaskKind,
|
||||
generation: dict[str, object],
|
||||
plan: RetrievalPlanV1,
|
||||
focus_state: Literal["resolved", "not_found", "ambiguous"],
|
||||
focus_node_id: str | None,
|
||||
focus_candidate_count: int,
|
||||
evidence: tuple[CapsuleEvidenceV1, ...],
|
||||
gaps: tuple[EvidenceGapV1, ...],
|
||||
omissions: tuple[CapsuleOmissionV1, ...],
|
||||
selected_count: int,
|
||||
examined_edge_count: int,
|
||||
estimated_tokens: int,
|
||||
unclassified_relations: tuple[str, ...],
|
||||
) -> ContextCapsuleV1:
|
||||
collection_hash = canonical_hash(
|
||||
{
|
||||
"generation": generation,
|
||||
"plan_hash": plan.plan_hash,
|
||||
"evidence": [item.evidence_hash for item in evidence],
|
||||
"gaps": [gap.as_dict() for gap in gaps],
|
||||
"omissions": [omission.as_dict() for omission in omissions],
|
||||
}
|
||||
)
|
||||
placeholder = ContextCapsuleV1(
|
||||
schema_version=1,
|
||||
state=state,
|
||||
task_kind=task_kind,
|
||||
generation=tuple(generation.items()),
|
||||
plan=plan,
|
||||
focus_state=focus_state,
|
||||
focus_node_id=focus_node_id,
|
||||
focus_candidate_count=focus_candidate_count,
|
||||
evidence=evidence,
|
||||
gaps=gaps,
|
||||
omissions=omissions,
|
||||
selected_count=selected_count,
|
||||
examined_edge_count=examined_edge_count,
|
||||
estimated_tokens=estimated_tokens,
|
||||
unclassified_relations=unclassified_relations,
|
||||
collection_hash=collection_hash,
|
||||
capsule_hash="",
|
||||
)
|
||||
capsule_hash = canonical_hash(
|
||||
{
|
||||
**placeholder.payload(include_hashes=False),
|
||||
"collection_hash": collection_hash,
|
||||
}
|
||||
)
|
||||
return replace(placeholder, capsule_hash=capsule_hash)
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
return max(1, (len(text) + 3) // 4)
|
||||
|
||||
|
||||
def edge_tuple(edge: Edge) -> tuple[str, str, str]:
|
||||
return edge.source_id, edge.relation, edge.target_id
|
||||
|
||||
|
||||
def _node_text(node: Node) -> str:
|
||||
return (
|
||||
f"ID: {node.node_id}\nTitle: {node.title}\nFamily: {node.family}\n"
|
||||
f"Authority: {node.authority}\nStatus: {node.status}\nSource: {node.source_path}\n"
|
||||
f"Summary: {node.summary}\n\n{node.content}"
|
||||
)
|
||||
|
||||
|
||||
def _bounded_value(
|
||||
value: int | None,
|
||||
*,
|
||||
default: int,
|
||||
maximum: int,
|
||||
code: str,
|
||||
) -> int:
|
||||
selected = default if value is None else value
|
||||
if type(selected) is not int or selected < 1 or selected > maximum:
|
||||
raise DocForgeError(code, "Task context limit is outside the configured range")
|
||||
return selected
|
||||
|
||||
|
||||
def _is_sha256(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, str)
|
||||
and len(value) == 64
|
||||
and all(character in "0123456789abcdef" for character in value)
|
||||
)
|
||||
|
|
@ -91,6 +91,7 @@ OPERATION_NAMES = frozenset(
|
|||
"mcp.dependencies",
|
||||
"mcp.impact",
|
||||
"mcp.context",
|
||||
"mcp.task_context",
|
||||
"mcp.validate_project",
|
||||
"mcp.render_status",
|
||||
"mcp.visualize",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue