chipgraph 0.1.0__py3-none-any.whl
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.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""The API-runtime answerer behind `chipgraph ask`: retrieve, ask a model, check, retry.
|
|
2
|
+
|
|
3
|
+
`answer_question` runs the same contract as the Claude Code path (`/chipgraph:ask`): the
|
|
4
|
+
model sees only the sources `ask_context` returned, must reply with an `AskAnswer` JSON
|
|
5
|
+
object, and the reply goes through `ask_check`. A rejected reply is sent back once with the
|
|
6
|
+
reasons; an answer that still fails is never shown: the result is "I don't know" with the
|
|
7
|
+
reasons. With no source at all the model is not called.
|
|
8
|
+
|
|
9
|
+
The provider comes from the profile's `models.providers` (`make_provider`), wrapped in a
|
|
10
|
+
`GuardedProvider` (the `nda` rule, retry, accounting); the model id is `models.tiers.small`.
|
|
11
|
+
With no provider configured, `run_ask` returns the retrieved sources only (`no_provider`).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
21
|
+
|
|
22
|
+
from chipgraph.adapters.llm import AnthropicProvider, FakeProvider, GuardedProvider, LlmError
|
|
23
|
+
from chipgraph.app.context import AppContext
|
|
24
|
+
from chipgraph.app.errors import AppError
|
|
25
|
+
from chipgraph.core.config.models import Profile
|
|
26
|
+
from chipgraph.core.contracts.types import DataLabel
|
|
27
|
+
from chipgraph.core.plugin_api.protocols import LlmProvider
|
|
28
|
+
from chipgraph.core.plugin_api.types import LlmMessage, LlmRequest
|
|
29
|
+
from chipgraph.packs.assist.ask._project import AskProject
|
|
30
|
+
from chipgraph.packs.assist.ask.check import check_answer
|
|
31
|
+
from chipgraph.packs.assist.ask.contract import (
|
|
32
|
+
UNKNOWN_ANSWER,
|
|
33
|
+
AskAnswer,
|
|
34
|
+
AskCheck,
|
|
35
|
+
AskContext,
|
|
36
|
+
)
|
|
37
|
+
from chipgraph.packs.assist.ask.retrieve import DEFAULT_LIMIT, retrieve
|
|
38
|
+
|
|
39
|
+
MAX_ATTEMPTS = 2
|
|
40
|
+
"""The first reply, plus one retry with the checker's reasons."""
|
|
41
|
+
|
|
42
|
+
MAX_TOKENS = 1024
|
|
43
|
+
|
|
44
|
+
AskStatus = Literal["answered", "unknown", "rejected", "no_provider"]
|
|
45
|
+
|
|
46
|
+
SYSTEM_PROMPT = """\
|
|
47
|
+
You answer one question about a chip design project, using ONLY the sources you are given
|
|
48
|
+
(entities of its Design Model and lines of its documents). Rules:
|
|
49
|
+
|
|
50
|
+
- Every fact in your answer must come from a source. Cite the sources you used, each by
|
|
51
|
+
its exact `citation` string ('model:<key>' or 'path:line'), or by its `defined_at`.
|
|
52
|
+
- Never use outside knowledge and never guess a number, a name or a behaviour.
|
|
53
|
+
- If the sources do not answer the question, set "unknown" to true, say briefly what is
|
|
54
|
+
missing in "answer", and cite nothing.
|
|
55
|
+
|
|
56
|
+
Reply with exactly one JSON object and nothing else:
|
|
57
|
+
{"answer": "<short answer>", "citations": ["<citation>", ...], "unknown": false}
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AskResult(BaseModel):
|
|
62
|
+
"""What `chipgraph ask` prints: the checked answer (or none), and the sources."""
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
65
|
+
|
|
66
|
+
schema_version: int = 1
|
|
67
|
+
status: AskStatus = Field(
|
|
68
|
+
description=(
|
|
69
|
+
"'answered' or 'unknown' (a verified answer), 'rejected' (no reply passed the "
|
|
70
|
+
"check: the answer is 'I don't know'), 'no_provider' (sources only)."
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
question: str
|
|
74
|
+
answer: AskAnswer | None = Field(default=None, description="The verified answer.")
|
|
75
|
+
check: AskCheck | None = Field(default=None, description="The last check that ran.")
|
|
76
|
+
attempts: int = Field(default=0, description="Model calls made.")
|
|
77
|
+
context: AskContext = Field(description="The retrieved sources.")
|
|
78
|
+
provider: str | None = Field(default=None, description="The provider used, if any.")
|
|
79
|
+
model: str | None = Field(default=None, description="The model id used, if any.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def make_provider(profile: Profile, name: str | None = None) -> LlmProvider | None:
|
|
83
|
+
"""The `LlmProvider` configured in `models.providers`, or None when there is none.
|
|
84
|
+
|
|
85
|
+
`name` picks one provider; it is required when several are configured. `anthropic`
|
|
86
|
+
is the Anthropic API, `fake` the scripted test provider, and any other name an
|
|
87
|
+
Anthropic-compatible endpoint (`base_url` in its config).
|
|
88
|
+
"""
|
|
89
|
+
providers = profile.models.providers
|
|
90
|
+
if name is None:
|
|
91
|
+
if not providers:
|
|
92
|
+
return None
|
|
93
|
+
if len(providers) > 1:
|
|
94
|
+
raise AppError(
|
|
95
|
+
f"several providers in models.providers ({', '.join(sorted(providers))}); "
|
|
96
|
+
"pick one with --provider"
|
|
97
|
+
)
|
|
98
|
+
[name] = providers
|
|
99
|
+
elif name not in providers:
|
|
100
|
+
known = ", ".join(sorted(providers)) or "none"
|
|
101
|
+
raise AppError(f"no provider {name!r} in models.providers (configured: {known})")
|
|
102
|
+
if name == "fake":
|
|
103
|
+
return FakeProvider(name="fake")
|
|
104
|
+
try:
|
|
105
|
+
return AnthropicProvider.from_models(profile.models, name)
|
|
106
|
+
except LlmError as exc:
|
|
107
|
+
raise AppError(str(exc)) from exc
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def run_ask(
|
|
111
|
+
ctx: AppContext,
|
|
112
|
+
question: str,
|
|
113
|
+
*,
|
|
114
|
+
limit: int = DEFAULT_LIMIT,
|
|
115
|
+
provider_name: str | None = None,
|
|
116
|
+
) -> AskResult:
|
|
117
|
+
"""`chipgraph ask`: answer with the profile's provider, or return the sources only."""
|
|
118
|
+
profile = ctx.require_profile().profile
|
|
119
|
+
project = AskProject.load(ctx)
|
|
120
|
+
provider = make_provider(profile, provider_name)
|
|
121
|
+
if provider is None:
|
|
122
|
+
return AskResult(
|
|
123
|
+
status="no_provider", question=question, context=retrieve(project, question, limit)
|
|
124
|
+
)
|
|
125
|
+
model = profile.models.tiers.get("small")
|
|
126
|
+
if not model:
|
|
127
|
+
raise AppError(
|
|
128
|
+
f"provider {provider.name!r} is configured but models.tiers.small is not: set the "
|
|
129
|
+
"model id /ask should use"
|
|
130
|
+
)
|
|
131
|
+
guarded = GuardedProvider(provider, data=profile.data)
|
|
132
|
+
return asyncio.run(answer_question(project, question, guarded, model=model, limit=limit))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def answer_question(
|
|
136
|
+
project: AskProject,
|
|
137
|
+
question: str,
|
|
138
|
+
provider: LlmProvider,
|
|
139
|
+
*,
|
|
140
|
+
model: str,
|
|
141
|
+
limit: int = DEFAULT_LIMIT,
|
|
142
|
+
) -> AskResult:
|
|
143
|
+
"""Retrieve, ask `provider`, check the reply, retry once with the reasons."""
|
|
144
|
+
context = retrieve(project, question, limit)
|
|
145
|
+
|
|
146
|
+
def result(
|
|
147
|
+
status: AskStatus, answer: AskAnswer, check: AskCheck | None, attempts: int
|
|
148
|
+
) -> AskResult:
|
|
149
|
+
return AskResult(
|
|
150
|
+
status=status,
|
|
151
|
+
question=question,
|
|
152
|
+
answer=answer,
|
|
153
|
+
check=check,
|
|
154
|
+
attempts=attempts,
|
|
155
|
+
context=context,
|
|
156
|
+
provider=provider.name,
|
|
157
|
+
model=model,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if context.no_sources:
|
|
161
|
+
return result("unknown", AskAnswer(answer=UNKNOWN_ANSWER, unknown=True), None, 0)
|
|
162
|
+
|
|
163
|
+
messages = [
|
|
164
|
+
LlmMessage(role="system", content=SYSTEM_PROMPT),
|
|
165
|
+
LlmMessage(role="user", content=_user_prompt(context)),
|
|
166
|
+
]
|
|
167
|
+
labels = _labels(project, context)
|
|
168
|
+
check: AskCheck | None = None
|
|
169
|
+
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
170
|
+
request = LlmRequest(
|
|
171
|
+
model=model, messages=tuple(messages), max_tokens=MAX_TOKENS, labels=labels
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
response = await provider.complete(request)
|
|
175
|
+
except LlmError as exc:
|
|
176
|
+
raise AppError(f"/ask: the model call failed: {exc}") from exc
|
|
177
|
+
parsed, parse_error = parse_answer(response.text)
|
|
178
|
+
if parsed is None:
|
|
179
|
+
check = AskCheck(ok=False, reasons=(parse_error or "not an answer object",))
|
|
180
|
+
else:
|
|
181
|
+
check = check_answer(project, parsed)
|
|
182
|
+
if check.ok and check.answer is not None:
|
|
183
|
+
status: AskStatus = "unknown" if check.answer.unknown else "answered"
|
|
184
|
+
return result(status, check.answer, check, attempt)
|
|
185
|
+
messages.append(LlmMessage(role="assistant", content=response.text))
|
|
186
|
+
messages.append(LlmMessage(role="user", content=_retry_prompt(check)))
|
|
187
|
+
|
|
188
|
+
fallback = AskAnswer(
|
|
189
|
+
answer="I don't know: no answer could be verified against this project's sources.",
|
|
190
|
+
unknown=True,
|
|
191
|
+
)
|
|
192
|
+
return result("rejected", fallback, check, MAX_ATTEMPTS)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse_answer(text: str) -> tuple[AskAnswer | None, str | None]:
|
|
196
|
+
"""An `AskAnswer` from a model reply (a JSON object, maybe fenced), or the error."""
|
|
197
|
+
raw = text.strip()
|
|
198
|
+
if raw.startswith("```"):
|
|
199
|
+
raw = raw.split("\n", 1)[1] if "\n" in raw else ""
|
|
200
|
+
raw = raw.rsplit("```", 1)[0]
|
|
201
|
+
start, end = raw.find("{"), raw.rfind("}")
|
|
202
|
+
if start < 0 or end < start:
|
|
203
|
+
return None, "the reply is not a JSON object"
|
|
204
|
+
try:
|
|
205
|
+
data = json.loads(raw[start : end + 1])
|
|
206
|
+
except json.JSONDecodeError as exc:
|
|
207
|
+
return None, f"the reply is not valid JSON: {exc.msg}"
|
|
208
|
+
if not isinstance(data, dict):
|
|
209
|
+
return None, "the reply is not a JSON object"
|
|
210
|
+
answer = data.get("answer", "")
|
|
211
|
+
citations = data.get("citations", [])
|
|
212
|
+
unknown = data.get("unknown", False)
|
|
213
|
+
if not isinstance(answer, str):
|
|
214
|
+
return None, "'answer' must be a string"
|
|
215
|
+
if not isinstance(citations, list) or not all(isinstance(c, str) for c in citations):
|
|
216
|
+
return None, "'citations' must be a list of strings"
|
|
217
|
+
if not isinstance(unknown, bool):
|
|
218
|
+
return None, "'unknown' must be true or false"
|
|
219
|
+
return AskAnswer(answer=answer, citations=tuple(citations), unknown=unknown), None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _user_prompt(context: AskContext) -> str:
|
|
223
|
+
sources = [s.model_dump(mode="json", exclude_defaults=True) for s in context.sources]
|
|
224
|
+
return (
|
|
225
|
+
f"Question: {context.question}\n\n"
|
|
226
|
+
f"Sources ({len(sources)}), best first:\n"
|
|
227
|
+
f"{json.dumps(sources, indent=1)}\n\n"
|
|
228
|
+
f"{context.note}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _retry_prompt(check: AskCheck) -> str:
|
|
233
|
+
reasons = "\n".join(f"- {r}" for r in check.reasons)
|
|
234
|
+
return (
|
|
235
|
+
"Your answer was rejected by the citation check:\n"
|
|
236
|
+
f"{reasons}\n"
|
|
237
|
+
"Reply again with one JSON object. Cite only `citation` or `defined_at` strings from "
|
|
238
|
+
"the sources above; if they do not answer the question, set unknown to true."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _labels(project: AskProject, context: AskContext) -> tuple[DataLabel, ...]:
|
|
243
|
+
"""The data labels of every file behind the sources (never 'nda': those are excluded)."""
|
|
244
|
+
files: set[str] = set()
|
|
245
|
+
for source in context.sources:
|
|
246
|
+
for citation in (source.citation, source.defined_at):
|
|
247
|
+
if citation and not citation.startswith("model:"):
|
|
248
|
+
files.add(citation.rsplit(":", 1)[0])
|
|
249
|
+
if source.kind == "model":
|
|
250
|
+
entity = project.model.get(source.citation.removeprefix("model:"))
|
|
251
|
+
if entity is not None and entity.source.file:
|
|
252
|
+
files.add(entity.source.file)
|
|
253
|
+
labels = {project.labels.label_for(f) for f in files} or {project.labels.default}
|
|
254
|
+
return tuple(sorted(labels))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
__all__ = [
|
|
258
|
+
"MAX_ATTEMPTS",
|
|
259
|
+
"SYSTEM_PROMPT",
|
|
260
|
+
"AskResult",
|
|
261
|
+
"AskStatus",
|
|
262
|
+
"answer_question",
|
|
263
|
+
"make_provider",
|
|
264
|
+
"parse_answer",
|
|
265
|
+
"run_ask",
|
|
266
|
+
]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""`ask_check`: verify an `/ask` answer's citations before anyone sees the answer.
|
|
2
|
+
|
|
3
|
+
Rules (DESIGN 4.8: no evidence, no claim):
|
|
4
|
+
|
|
5
|
+
- A citation is either `path:line` / `path:start-end` (the path must be an indexed
|
|
6
|
+
document and every line must exist; a range spans at most `MAX_RANGE` lines) or
|
|
7
|
+
`model:<key>` (the key must exist in the Design Model). A bare model key is accepted and
|
|
8
|
+
written back as `model:<key>`. Nothing from an `nda` file is citable.
|
|
9
|
+
- Every citation must be valid: one invented citation rejects the answer.
|
|
10
|
+
- An answer with `unknown: false` needs at least one valid citation and some text.
|
|
11
|
+
- An answer with `unknown: true` needs no citation (and any it gives must be valid).
|
|
12
|
+
|
|
13
|
+
The result is the verified answer (citations in canonical form, duplicates dropped) or the
|
|
14
|
+
reasons it was rejected, with a verdict per citation so the answerer can fix it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import difflib
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from chipgraph.app.context import AppContext
|
|
23
|
+
from chipgraph.packs.assist.ask._project import AskProject
|
|
24
|
+
from chipgraph.packs.assist.ask.contract import (
|
|
25
|
+
UNKNOWN_ANSWER,
|
|
26
|
+
AskAnswer,
|
|
27
|
+
AskCheck,
|
|
28
|
+
CitationCheck,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
MAX_RANGE = 30
|
|
32
|
+
"""A `path:start-end` citation may span at most this many lines."""
|
|
33
|
+
|
|
34
|
+
MAX_CITATIONS = 20
|
|
35
|
+
"""An answer may carry at most this many citations."""
|
|
36
|
+
|
|
37
|
+
_FILE_LINE = re.compile(r"^(?P<path>.+?):(?P<start>\d+)(?:-(?P<end>\d+))?$")
|
|
38
|
+
_MAX_TEXT = 400
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ask_check(ctx: AppContext, answer: AskAnswer) -> AskCheck:
|
|
42
|
+
"""Verify `answer` against the project of `ctx` (see the module docstring)."""
|
|
43
|
+
return check_answer(AskProject.load(ctx), answer)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_answer(project: AskProject, answer: AskAnswer) -> AskCheck:
|
|
47
|
+
"""`ask_check` on an already loaded `AskProject`."""
|
|
48
|
+
reasons: list[str] = []
|
|
49
|
+
checks = [check_citation(project, c) for c in answer.citations[:MAX_CITATIONS]]
|
|
50
|
+
if len(answer.citations) > MAX_CITATIONS:
|
|
51
|
+
reasons.append(
|
|
52
|
+
f"too many citations ({len(answer.citations)}); cite at most {MAX_CITATIONS}"
|
|
53
|
+
)
|
|
54
|
+
for check in checks:
|
|
55
|
+
if not check.valid:
|
|
56
|
+
reasons.append(f"invalid citation {check.citation!r}: {check.reason}")
|
|
57
|
+
valid = [c for c in checks if c.valid]
|
|
58
|
+
text = answer.answer.strip()
|
|
59
|
+
if not answer.unknown:
|
|
60
|
+
if not text:
|
|
61
|
+
reasons.append("the answer text is empty")
|
|
62
|
+
if not valid:
|
|
63
|
+
reasons.append(
|
|
64
|
+
"an answer needs at least one valid citation ('path:line' or 'model:<key>' "
|
|
65
|
+
"from ask_context); if the sources do not answer the question, set unknown: true"
|
|
66
|
+
)
|
|
67
|
+
if reasons:
|
|
68
|
+
return AskCheck(ok=False, citations=tuple(checks), reasons=tuple(reasons))
|
|
69
|
+
|
|
70
|
+
canonical: list[str] = []
|
|
71
|
+
for check in valid:
|
|
72
|
+
if check.normalized is not None and check.normalized not in canonical:
|
|
73
|
+
canonical.append(check.normalized)
|
|
74
|
+
verified = AskAnswer(
|
|
75
|
+
answer=text or UNKNOWN_ANSWER,
|
|
76
|
+
citations=tuple(canonical),
|
|
77
|
+
unknown=answer.unknown,
|
|
78
|
+
)
|
|
79
|
+
return AskCheck(ok=True, answer=verified, citations=tuple(checks))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check_citation(project: AskProject, citation: str) -> CitationCheck:
|
|
83
|
+
"""The verdict on one citation string."""
|
|
84
|
+
raw = citation
|
|
85
|
+
text = citation.strip()
|
|
86
|
+
if not text:
|
|
87
|
+
return CitationCheck(citation=raw, valid=False, reason="empty citation")
|
|
88
|
+
if text.startswith("model:"):
|
|
89
|
+
return _check_key(project, raw, text.removeprefix("model:").strip())
|
|
90
|
+
match = _FILE_LINE.match(text)
|
|
91
|
+
if match is not None:
|
|
92
|
+
path = _normalise_path(match.group("path"))
|
|
93
|
+
if project.docs.is_indexed(path) or project.model.get(text) is None:
|
|
94
|
+
return _check_lines(project, raw, path, match.group("start"), match.group("end"))
|
|
95
|
+
if project.model.get(text) is not None:
|
|
96
|
+
return _check_key(project, raw, text)
|
|
97
|
+
return CitationCheck(
|
|
98
|
+
citation=raw,
|
|
99
|
+
valid=False,
|
|
100
|
+
reason=(
|
|
101
|
+
"not a 'path:line' or 'model:<key>' citation"
|
|
102
|
+
+ _did_you_mean(text, list(project.model.entities))
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _check_key(project: AskProject, raw: str, key: str) -> CitationCheck:
|
|
108
|
+
entity = project.model.get(key)
|
|
109
|
+
if entity is None:
|
|
110
|
+
return CitationCheck(
|
|
111
|
+
citation=raw,
|
|
112
|
+
valid=False,
|
|
113
|
+
kind="model",
|
|
114
|
+
reason=f"no model key {key!r}" + _did_you_mean(key, list(project.model.entities)),
|
|
115
|
+
)
|
|
116
|
+
if not project.visible(entity):
|
|
117
|
+
return CitationCheck(
|
|
118
|
+
citation=raw,
|
|
119
|
+
valid=False,
|
|
120
|
+
kind="model",
|
|
121
|
+
reason=f"{key!r} comes from an 'nda' file, which /ask may not cite",
|
|
122
|
+
)
|
|
123
|
+
return CitationCheck(
|
|
124
|
+
citation=raw,
|
|
125
|
+
valid=True,
|
|
126
|
+
kind="model",
|
|
127
|
+
normalized=f"model:{key}",
|
|
128
|
+
text=_clip(project.summary(entity)),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_lines(
|
|
133
|
+
project: AskProject, raw: str, path: str, start_text: str, end_text: str | None
|
|
134
|
+
) -> CitationCheck:
|
|
135
|
+
counts = project.docs.line_counts()
|
|
136
|
+
if path not in counts:
|
|
137
|
+
label = project.labels.label_for(path)
|
|
138
|
+
why = (
|
|
139
|
+
f"{path} is labelled 'nda' and is never indexed for /ask"
|
|
140
|
+
if label == "nda"
|
|
141
|
+
else f"{path} is not an indexed document" + _did_you_mean(path, list(counts))
|
|
142
|
+
)
|
|
143
|
+
return CitationCheck(citation=raw, valid=False, kind="document", reason=why)
|
|
144
|
+
start = int(start_text)
|
|
145
|
+
end = int(end_text) if end_text is not None else start
|
|
146
|
+
total = counts[path]
|
|
147
|
+
if start < 1 or end < start:
|
|
148
|
+
reason = f"bad line range {start}-{end}"
|
|
149
|
+
elif end > total:
|
|
150
|
+
reason = f"{path} has {total} lines; line {end} does not exist"
|
|
151
|
+
elif end - start + 1 > MAX_RANGE:
|
|
152
|
+
reason = f"a range may span at most {MAX_RANGE} lines; cite the exact lines"
|
|
153
|
+
else:
|
|
154
|
+
rows = project.docs.lines(path, start, end)
|
|
155
|
+
normalized = f"{path}:{start}" if start == end else f"{path}:{start}-{end}"
|
|
156
|
+
return CitationCheck(
|
|
157
|
+
citation=raw,
|
|
158
|
+
valid=True,
|
|
159
|
+
kind="document",
|
|
160
|
+
normalized=normalized,
|
|
161
|
+
text=_clip("\n".join(t for _, t in rows)),
|
|
162
|
+
)
|
|
163
|
+
return CitationCheck(citation=raw, valid=False, kind="document", reason=reason)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _normalise_path(path: str) -> str:
|
|
167
|
+
text = path.strip().replace("\\", "/")
|
|
168
|
+
while text.startswith("./"):
|
|
169
|
+
text = text[2:]
|
|
170
|
+
return text
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _did_you_mean(name: str, choices: list[str]) -> str:
|
|
174
|
+
matches = difflib.get_close_matches(name, choices, n=3, cutoff=0.6)
|
|
175
|
+
return f"; did you mean: {', '.join(matches)}" if matches else ""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _clip(text: str) -> str:
|
|
179
|
+
return text if len(text) <= _MAX_TEXT else text[: _MAX_TEXT - 3] + "..."
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = ["MAX_CITATIONS", "MAX_RANGE", "ask_check", "check_answer", "check_citation"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The `/ask` data contracts: retrieved sources, the answer, and the citation check.
|
|
2
|
+
|
|
3
|
+
An answer is `{"answer": "...", "citations": ["file:line" | "model:<key>", ...],
|
|
4
|
+
"unknown": bool}` (`AskAnswer`). `ask_check` verifies it and returns an `AskCheck`: the
|
|
5
|
+
verified answer, or the reasons it was rejected. Every model is JSON-dumpable, frozen, and
|
|
6
|
+
carries a `schema_version`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
UNKNOWN_ANSWER = "I don't know: no source in this project's Design Model or documents answers it."
|
|
16
|
+
"""The answer text used when there is no source, or no answer could be verified."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DocLine(BaseModel):
|
|
20
|
+
"""One line of an indexed document."""
|
|
21
|
+
|
|
22
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
23
|
+
|
|
24
|
+
line: int = Field(ge=1, description="1-based line number.")
|
|
25
|
+
text: str = Field(description="The line's text.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AskSource(BaseModel):
|
|
29
|
+
"""One source `ask_context` returned: a model entity or a document line."""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
32
|
+
|
|
33
|
+
citation: str = Field(description="What to cite: 'model:<key>' or 'path:line'.")
|
|
34
|
+
kind: Literal["model", "document"] = Field(description="A model entity or a document line.")
|
|
35
|
+
text: str = Field(description="The entity's facts, or the document line.")
|
|
36
|
+
origin: Literal["lookup", "search"] = Field(
|
|
37
|
+
description="'lookup': a typed model query on a name in the question; 'search': FTS5."
|
|
38
|
+
)
|
|
39
|
+
defined_at: str | None = Field(
|
|
40
|
+
default=None,
|
|
41
|
+
description="For a model entity: the indexed 'path:line' it was read from, also citable.",
|
|
42
|
+
)
|
|
43
|
+
context: tuple[DocLine, ...] = Field(
|
|
44
|
+
default=(), description="For a document line: the lines around it."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AskContext(BaseModel):
|
|
49
|
+
"""The result of `ask_context`: the sources an answer may use, and only those."""
|
|
50
|
+
|
|
51
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
52
|
+
|
|
53
|
+
schema_version: int = 1
|
|
54
|
+
question: str = Field(description="The question, as asked.")
|
|
55
|
+
query: str | None = Field(description="The FTS5 query used, or None when no term was left.")
|
|
56
|
+
sources: tuple[AskSource, ...] = Field(default=(), description="Best first.")
|
|
57
|
+
no_sources: bool = Field(description="True when nothing matched: the answer is 'unknown'.")
|
|
58
|
+
note: str = Field(description="How to use the sources.")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AskAnswer(BaseModel):
|
|
62
|
+
"""An answer to one `/ask` question: text, citations, and whether it is 'unknown'."""
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
65
|
+
|
|
66
|
+
schema_version: int = 1
|
|
67
|
+
answer: str = Field(description="The answer text.")
|
|
68
|
+
citations: tuple[str, ...] = Field(
|
|
69
|
+
default=(), description="'path:line' (or 'path:start-end') or 'model:<key>'."
|
|
70
|
+
)
|
|
71
|
+
unknown: bool = Field(
|
|
72
|
+
default=False, description="True when the sources do not answer the question."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CitationCheck(BaseModel):
|
|
77
|
+
"""The verdict on one citation."""
|
|
78
|
+
|
|
79
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
80
|
+
|
|
81
|
+
citation: str = Field(description="The citation as given.")
|
|
82
|
+
valid: bool = Field(description="True when it names an indexed line or a model key.")
|
|
83
|
+
kind: Literal["model", "document"] | None = Field(
|
|
84
|
+
default=None, description="What it names, when that could be told."
|
|
85
|
+
)
|
|
86
|
+
normalized: str | None = Field(
|
|
87
|
+
default=None, description="The canonical form: 'path:line[-end]' or 'model:<key>'."
|
|
88
|
+
)
|
|
89
|
+
text: str | None = Field(default=None, description="What it points at, when valid.")
|
|
90
|
+
reason: str | None = Field(default=None, description="Why it is invalid.")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AskCheck(BaseModel):
|
|
94
|
+
"""The result of `ask_check`: the verified answer (when `ok`) or the reasons."""
|
|
95
|
+
|
|
96
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
97
|
+
|
|
98
|
+
schema_version: int = 1
|
|
99
|
+
ok: bool = Field(description="True when the answer is accepted.")
|
|
100
|
+
answer: AskAnswer | None = Field(
|
|
101
|
+
default=None, description="The verified answer (canonical citations), when ok."
|
|
102
|
+
)
|
|
103
|
+
citations: tuple[CitationCheck, ...] = Field(default=(), description="One per citation.")
|
|
104
|
+
reasons: tuple[str, ...] = Field(default=(), description="Why it was rejected.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = [
|
|
108
|
+
"UNKNOWN_ANSWER",
|
|
109
|
+
"AskAnswer",
|
|
110
|
+
"AskCheck",
|
|
111
|
+
"AskContext",
|
|
112
|
+
"AskSource",
|
|
113
|
+
"CitationCheck",
|
|
114
|
+
"DocLine",
|
|
115
|
+
]
|