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,157 @@
|
|
|
1
|
+
"""Which project files `/ask` may search, and indexing them into the model store.
|
|
2
|
+
|
|
3
|
+
`ModelStore.write()` rebuilds the database from scratch, which drops every document added
|
|
4
|
+
with `ModelStore.add_document`. So `chipgraph ingest` calls `index_documents` right after
|
|
5
|
+
it writes the model: every ingest re-indexes the same, deterministic document set.
|
|
6
|
+
|
|
7
|
+
The document set is:
|
|
8
|
+
|
|
9
|
+
- every input file ingest read (the chip-level spec, each block's spec, each filelist and
|
|
10
|
+
the RTL sources it lists), and
|
|
11
|
+
- the project's own Markdown docs: `README.md`, `AGENTS.md`, `doc/**/*.md`, `docs/**/*.md`,
|
|
12
|
+
|
|
13
|
+
minus files labelled `nda` (every `/ask` model may be a cloud model, so an `nda` file is
|
|
14
|
+
never indexed; ingest reports each one it skipped), vendored paths (a `vendor`,
|
|
15
|
+
`third_party`, ... path segment), paths outside the project, and binary or very large files.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
25
|
+
|
|
26
|
+
from chipgraph.core.model.store import ModelStore
|
|
27
|
+
from chipgraph.core.state.artifacts import LabelRules
|
|
28
|
+
|
|
29
|
+
MARKDOWN_DOCS: tuple[str, ...] = ("README.md", "AGENTS.md", "doc/**/*.md", "docs/**/*.md")
|
|
30
|
+
"""Globs (relative to the project root) of the Markdown docs indexed besides ingest inputs."""
|
|
31
|
+
|
|
32
|
+
VENDOR_SEGMENTS = frozenset({"vendor", "third_party", "thirdparty", "external", "extern"})
|
|
33
|
+
"""A path with one of these segments is vendored code: never indexed."""
|
|
34
|
+
|
|
35
|
+
MAX_DOCUMENT_BYTES = 2 * 1024 * 1024
|
|
36
|
+
"""Files larger than this (generated netlists, dumps) are not indexed."""
|
|
37
|
+
|
|
38
|
+
SkipReason = Literal["nda", "vendor", "outside_root", "missing", "binary", "too_large"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SkippedDocument(BaseModel):
|
|
42
|
+
"""A candidate document that was not indexed, and why."""
|
|
43
|
+
|
|
44
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
45
|
+
|
|
46
|
+
path: str = Field(description="Repo-relative POSIX path (or the path as given).")
|
|
47
|
+
reason: SkipReason = Field(description="Why it was not indexed.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DocumentIndexReport(BaseModel):
|
|
51
|
+
"""What `index_documents` indexed for `/ask`, and what it skipped."""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
54
|
+
|
|
55
|
+
schema_version: int = 1
|
|
56
|
+
indexed: tuple[str, ...] = Field(default=(), description="Indexed paths, sorted.")
|
|
57
|
+
lines: int = Field(default=0, description="Total number of indexed lines.")
|
|
58
|
+
skipped: tuple[SkippedDocument, ...] = Field(
|
|
59
|
+
default=(), description="Candidates not indexed, sorted by path."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def nda_skipped(self) -> tuple[str, ...]:
|
|
64
|
+
"""The paths skipped because they are labelled `nda`."""
|
|
65
|
+
return tuple(s.path for s in self.skipped if s.reason == "nda")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def document_candidates(root: Path, input_paths: Iterable[str]) -> list[str]:
|
|
69
|
+
"""Every candidate document path: ingest inputs plus the Markdown docs, sorted, unique."""
|
|
70
|
+
candidates = {_normalise(p) for p in input_paths}
|
|
71
|
+
for pattern in MARKDOWN_DOCS:
|
|
72
|
+
for path in root.glob(pattern):
|
|
73
|
+
if path.is_file():
|
|
74
|
+
candidates.add(_relative(path, root))
|
|
75
|
+
return sorted(candidates)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def index_documents(
|
|
79
|
+
root: Path,
|
|
80
|
+
store: ModelStore,
|
|
81
|
+
input_paths: Iterable[str],
|
|
82
|
+
labels: LabelRules,
|
|
83
|
+
) -> DocumentIndexReport:
|
|
84
|
+
"""Index the `/ask` document set of the project at `root` into `store`.
|
|
85
|
+
|
|
86
|
+
Call it after `store.write()`, which drops earlier documents. Deterministic: the same
|
|
87
|
+
files give the same rows, in the same order.
|
|
88
|
+
"""
|
|
89
|
+
indexed: list[str] = []
|
|
90
|
+
skipped: list[SkippedDocument] = []
|
|
91
|
+
lines = 0
|
|
92
|
+
for rel in document_candidates(root, input_paths):
|
|
93
|
+
reason = _skip_reason(rel, root, labels)
|
|
94
|
+
if reason is not None:
|
|
95
|
+
skipped.append(SkippedDocument(path=rel, reason=reason))
|
|
96
|
+
continue
|
|
97
|
+
data = (root / rel).read_bytes()
|
|
98
|
+
if len(data) > MAX_DOCUMENT_BYTES:
|
|
99
|
+
skipped.append(SkippedDocument(path=rel, reason="too_large"))
|
|
100
|
+
continue
|
|
101
|
+
if b"\x00" in data:
|
|
102
|
+
skipped.append(SkippedDocument(path=rel, reason="binary"))
|
|
103
|
+
continue
|
|
104
|
+
text = data.decode("utf-8", errors="replace")
|
|
105
|
+
store.add_document(rel, text)
|
|
106
|
+
indexed.append(rel)
|
|
107
|
+
lines += len(text.splitlines())
|
|
108
|
+
return DocumentIndexReport(indexed=tuple(indexed), lines=lines, skipped=tuple(skipped))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_vendor_path(rel: str) -> bool:
|
|
112
|
+
"""True when `rel` has a vendored-code path segment."""
|
|
113
|
+
return any(part.lower() in VENDOR_SEGMENTS for part in PurePosixPath(rel).parts)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _skip_reason(rel: str, root: Path, labels: LabelRules) -> SkipReason | None:
|
|
117
|
+
posix = PurePosixPath(rel)
|
|
118
|
+
if posix.is_absolute() or ".." in posix.parts:
|
|
119
|
+
return "outside_root"
|
|
120
|
+
if labels.label_for(rel) == "nda":
|
|
121
|
+
return "nda"
|
|
122
|
+
if is_vendor_path(rel):
|
|
123
|
+
return "vendor"
|
|
124
|
+
path = root / rel
|
|
125
|
+
if not path.is_file():
|
|
126
|
+
return "missing"
|
|
127
|
+
try:
|
|
128
|
+
path.resolve().relative_to(root.resolve())
|
|
129
|
+
except ValueError:
|
|
130
|
+
return "outside_root" # a symlink out of the project
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _normalise(rel: str) -> str:
|
|
135
|
+
text = rel.replace("\\", "/")
|
|
136
|
+
while text.startswith("./"):
|
|
137
|
+
text = text[2:]
|
|
138
|
+
return text
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _relative(path: Path, root: Path) -> str:
|
|
142
|
+
try:
|
|
143
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
144
|
+
except ValueError:
|
|
145
|
+
return path.as_posix()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
__all__ = [
|
|
149
|
+
"MARKDOWN_DOCS",
|
|
150
|
+
"MAX_DOCUMENT_BYTES",
|
|
151
|
+
"VENDOR_SEGMENTS",
|
|
152
|
+
"DocumentIndexReport",
|
|
153
|
+
"SkippedDocument",
|
|
154
|
+
"document_candidates",
|
|
155
|
+
"index_documents",
|
|
156
|
+
"is_vendor_path",
|
|
157
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""A safe FTS5 query from a free-text question (FTS5 syntax never reaches SQLite raw).
|
|
2
|
+
|
|
3
|
+
The question is tokenised into words (Unicode letters, digits, `_`, with inner `-` or
|
|
4
|
+
`.`, so `REQ-TIM-004`, `CTRL.EN` and `pin_out` stay one term). English stop words and
|
|
5
|
+
one-character words are dropped, a plural gets its singular as an extra term, every term
|
|
6
|
+
is quoted as an FTS5 string, and the terms are ORed. A quoted string is never an FTS5
|
|
7
|
+
operator (`NEAR`, `AND`, `*`, `^`, `col:`), and the tokeniser splits it into a phrase
|
|
8
|
+
exactly as it split the indexed text. A question with no usable term gives `None`: no
|
|
9
|
+
search at all.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
MAX_TERMS = 32
|
|
17
|
+
"""At most this many terms go into one query (the first ones in the question)."""
|
|
18
|
+
|
|
19
|
+
_TERM = re.compile(r"\w+(?:[-.]\w+)*")
|
|
20
|
+
|
|
21
|
+
_STOP_WORDS_TEXT = """
|
|
22
|
+
a about above after again all also am an and any are as at be been before being below
|
|
23
|
+
between both but by can could did do does doing done down during each either else
|
|
24
|
+
for from further get gets give given had has have having he her here hers how however
|
|
25
|
+
i if in into is it its itself just let me more most much must my no nor not now of
|
|
26
|
+
off on once only or other our out over own per please same she should show so some
|
|
27
|
+
such tell than that the their them then there these they this those through to too
|
|
28
|
+
under until up upon us very via was we were what whats when where whether which while
|
|
29
|
+
who whom whose why will with within without would yes you your
|
|
30
|
+
"""
|
|
31
|
+
STOP_WORDS = frozenset(_STOP_WORDS_TEXT.split())
|
|
32
|
+
"""English function words and question words: never a search term on their own.
|
|
33
|
+
|
|
34
|
+
`out`, `in` and `no` are stop words too: as ports (`pin_out`) they are kept inside the
|
|
35
|
+
longer term, and a typed lookup still finds an entity named exactly that.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def question_terms(question: str) -> list[str]:
|
|
40
|
+
"""The search terms of `question`, lowercased, in order, without duplicates."""
|
|
41
|
+
terms: list[str] = []
|
|
42
|
+
seen: set[str] = set()
|
|
43
|
+
for match in _TERM.finditer(question):
|
|
44
|
+
word = match.group(0).strip("-.").lower()
|
|
45
|
+
if len(word) < 2 or word in STOP_WORDS:
|
|
46
|
+
continue
|
|
47
|
+
for term in (word, _singular(word)):
|
|
48
|
+
if term and term not in seen and term not in STOP_WORDS:
|
|
49
|
+
seen.add(term)
|
|
50
|
+
terms.append(term)
|
|
51
|
+
return terms[:MAX_TERMS]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fts_query(question: str) -> str | None:
|
|
55
|
+
"""A safe FTS5 MATCH expression for `question`: quoted terms ORed, or `None`."""
|
|
56
|
+
terms = question_terms(question)
|
|
57
|
+
if not terms:
|
|
58
|
+
return None
|
|
59
|
+
return " OR ".join(_quote(term) for term in terms)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _quote(term: str) -> str:
|
|
63
|
+
# Terms only hold word characters, `-` and `.`; doubling `"` keeps this safe anyway.
|
|
64
|
+
return '"' + term.replace('"', '""') + '"'
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _singular(word: str) -> str | None:
|
|
68
|
+
"""A naive singular for an English plural (`registers` -> `register`), else None."""
|
|
69
|
+
if not word.isalpha() or len(word) < 5:
|
|
70
|
+
return None
|
|
71
|
+
if word.endswith("ies"):
|
|
72
|
+
return word[:-3] + "y"
|
|
73
|
+
if word.endswith("sses") or word.endswith("xes"):
|
|
74
|
+
return word[:-2]
|
|
75
|
+
if word.endswith("s") and not word.endswith(("ss", "us", "is")):
|
|
76
|
+
return word[:-1]
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["MAX_TERMS", "STOP_WORDS", "fts_query", "question_terms"]
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""`ask_context`: the sources an `/ask` answer may use (DESIGN 4.5: typed queries, no grep).
|
|
2
|
+
|
|
3
|
+
Two kinds of retrieval, merged:
|
|
4
|
+
|
|
5
|
+
1. **Typed lookups** (`ModelQuery`): a word of the question that names something the model
|
|
6
|
+
knows (a block, module, register, `REG.FIELD`, port, requirement id, clock, reset, ...)
|
|
7
|
+
gives that entity, with its facts and relations; a kind word (`register`, `interrupt`,
|
|
8
|
+
`open items`, `memory map`, ...) lists the entities of that kind in the blocks the
|
|
9
|
+
question is about. A port named `clk` exists in every module, so an entity in a block
|
|
10
|
+
the question names ranks above one in another block.
|
|
11
|
+
2. **Full-text search** (FTS5 over entities and documents) with a safe query built from
|
|
12
|
+
the question (`fts.fts_query`). A document hit cites `path:line` and carries the lines
|
|
13
|
+
around it.
|
|
14
|
+
|
|
15
|
+
Entities and documents from `nda` files are never returned. An empty result is a valid
|
|
16
|
+
answer: `no_sources` is then true and the answer must be "I don't know".
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import sqlite3
|
|
23
|
+
|
|
24
|
+
from chipgraph.app.context import AppContext
|
|
25
|
+
from chipgraph.core.model.entities import EntityBase
|
|
26
|
+
from chipgraph.packs.assist.ask._project import AskProject
|
|
27
|
+
from chipgraph.packs.assist.ask.contract import AskContext, AskSource, DocLine
|
|
28
|
+
from chipgraph.packs.assist.ask.fts import STOP_WORDS, fts_query
|
|
29
|
+
|
|
30
|
+
DEFAULT_LIMIT = 12
|
|
31
|
+
MAX_LIMIT = 50
|
|
32
|
+
CONTEXT_LINES = 2
|
|
33
|
+
"""Lines shown before and after a document hit."""
|
|
34
|
+
|
|
35
|
+
_WORD = re.compile(r"\w+(?:[-.]\w+)*")
|
|
36
|
+
|
|
37
|
+
_KIND_PRIORITY: dict[str, int] = {
|
|
38
|
+
"requirement": 10,
|
|
39
|
+
"register": 9,
|
|
40
|
+
"field": 8,
|
|
41
|
+
"interrupt": 8,
|
|
42
|
+
"memory_region": 7,
|
|
43
|
+
"block": 6,
|
|
44
|
+
"module": 6,
|
|
45
|
+
"port": 5,
|
|
46
|
+
"clock": 5,
|
|
47
|
+
"reset": 5,
|
|
48
|
+
"parameter": 4,
|
|
49
|
+
"interface": 4,
|
|
50
|
+
"open_item": 4,
|
|
51
|
+
"project": 3,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_KIND_WORDS: dict[str, str] = {
|
|
55
|
+
"register": "register",
|
|
56
|
+
"registers": "register",
|
|
57
|
+
"field": "field",
|
|
58
|
+
"fields": "field",
|
|
59
|
+
"bitfield": "field",
|
|
60
|
+
"bitfields": "field",
|
|
61
|
+
"requirement": "requirement",
|
|
62
|
+
"requirements": "requirement",
|
|
63
|
+
"interrupt": "interrupt",
|
|
64
|
+
"interrupts": "interrupt",
|
|
65
|
+
"port": "port",
|
|
66
|
+
"ports": "port",
|
|
67
|
+
"signal": "port",
|
|
68
|
+
"signals": "port",
|
|
69
|
+
"module": "module",
|
|
70
|
+
"modules": "module",
|
|
71
|
+
"parameter": "parameter",
|
|
72
|
+
"parameters": "parameter",
|
|
73
|
+
"clock": "clock",
|
|
74
|
+
"clocks": "clock",
|
|
75
|
+
"reset": "reset",
|
|
76
|
+
"resets": "reset",
|
|
77
|
+
"block": "block",
|
|
78
|
+
"blocks": "block",
|
|
79
|
+
"bus": "interface",
|
|
80
|
+
"buses": "interface",
|
|
81
|
+
"interface": "interface",
|
|
82
|
+
"interfaces": "interface",
|
|
83
|
+
"address": "memory_region",
|
|
84
|
+
"addresses": "memory_region",
|
|
85
|
+
}
|
|
86
|
+
_PHRASE_KINDS: tuple[tuple[str, str], ...] = (
|
|
87
|
+
("open item", "open_item"),
|
|
88
|
+
("open question", "open_item"),
|
|
89
|
+
("memory map", "memory_region"),
|
|
90
|
+
("address map", "memory_region"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
_GENERIC_WORDS = frozenset(
|
|
94
|
+
{
|
|
95
|
+
"access", "address", "base", "block", "clock", "config", "control", "count", "data",
|
|
96
|
+
"enable", "field", "interrupt", "line", "mode", "module", "offset", "port", "register",
|
|
97
|
+
"reset", "size", "status", "type", "value", "width",
|
|
98
|
+
}
|
|
99
|
+
) # fmt: skip
|
|
100
|
+
"""Words that are also common register or field names: they match an entity only when
|
|
101
|
+
written exactly as its name."""
|
|
102
|
+
|
|
103
|
+
_DIRECT_BONUS = 10
|
|
104
|
+
_KIND_WORD_BONUS = 6
|
|
105
|
+
_CONTEXT_BONUS = 5
|
|
106
|
+
_OFF_CONTEXT_PENALTY = 6
|
|
107
|
+
_MAX_PER_KIND_LISTING = 8
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def ask_context(ctx: AppContext, question: str, limit: int = DEFAULT_LIMIT) -> AskContext:
|
|
111
|
+
"""The sources for `question` in the project of `ctx`, best first, at most `limit`."""
|
|
112
|
+
project = AskProject.load(ctx)
|
|
113
|
+
return retrieve(project, question, limit)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def retrieve(project: AskProject, question: str, limit: int = DEFAULT_LIMIT) -> AskContext:
|
|
117
|
+
"""`ask_context` on an already loaded `AskProject`."""
|
|
118
|
+
limit = max(1, min(limit, MAX_LIMIT))
|
|
119
|
+
typed = _typed_sources(project, question, limit)
|
|
120
|
+
query = fts_query(question)
|
|
121
|
+
searched = _search_sources(project, query, limit) if query is not None else []
|
|
122
|
+
|
|
123
|
+
sources: list[AskSource] = []
|
|
124
|
+
seen: set[str] = set()
|
|
125
|
+
|
|
126
|
+
def take(items: list[AskSource], cap: int) -> None:
|
|
127
|
+
for source in items:
|
|
128
|
+
if len(sources) >= cap:
|
|
129
|
+
return
|
|
130
|
+
if source.citation not in seen:
|
|
131
|
+
seen.add(source.citation)
|
|
132
|
+
sources.append(source)
|
|
133
|
+
|
|
134
|
+
take(typed, max(1, (limit + 1) // 2))
|
|
135
|
+
take(searched, limit)
|
|
136
|
+
take(typed, limit)
|
|
137
|
+
|
|
138
|
+
no_sources = not sources
|
|
139
|
+
note = (
|
|
140
|
+
"No source matches the question: answer that you do not know (unknown: true)."
|
|
141
|
+
if no_sources
|
|
142
|
+
else "Answer only from these sources. Cite every claim with a source's `citation` "
|
|
143
|
+
"(or its `defined_at`) exactly as given. If they do not answer the question, set "
|
|
144
|
+
"unknown: true and say what is missing. Do not use outside knowledge."
|
|
145
|
+
)
|
|
146
|
+
return AskContext(
|
|
147
|
+
question=question,
|
|
148
|
+
query=query,
|
|
149
|
+
sources=tuple(sources),
|
|
150
|
+
no_sources=no_sources,
|
|
151
|
+
note=note,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- typed lookups ----------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _typed_sources(project: AskProject, question: str, limit: int) -> list[AskSource]:
|
|
159
|
+
"""Typed lookups on the names and kind words of the question, best first.
|
|
160
|
+
|
|
161
|
+
Score = kind priority, + `_DIRECT_BONUS` for an entity the question names, +
|
|
162
|
+
`_KIND_WORD_BONUS` when its kind is named too (or it is listed for a kind word in the
|
|
163
|
+
blocks the question is about), +/- the block context. An entity that only shares its
|
|
164
|
+
name with a named block (the `timer` memory region when the question says "timer")
|
|
165
|
+
is not "named" unless its kind is.
|
|
166
|
+
"""
|
|
167
|
+
words = [m.group(0).strip("-.") for m in _WORD.finditer(question)]
|
|
168
|
+
words = [w for w in words if len(w) >= 2]
|
|
169
|
+
kinds = _kinds_named(words)
|
|
170
|
+
names: dict[str, list[EntityBase]] = {}
|
|
171
|
+
for entity in project.model.entities.values():
|
|
172
|
+
if entity.kind in _KIND_PRIORITY and project.visible(entity):
|
|
173
|
+
names.setdefault(entity.name.lower(), []).append(entity)
|
|
174
|
+
|
|
175
|
+
direct: dict[str, EntityBase] = {}
|
|
176
|
+
for word in words:
|
|
177
|
+
lowered = word.lower()
|
|
178
|
+
if lowered in STOP_WORDS:
|
|
179
|
+
continue
|
|
180
|
+
for entity in names.get(lowered, ()):
|
|
181
|
+
# A generic word ("reset value", "status") names an entity only when it is
|
|
182
|
+
# written exactly as that entity's name (`RESET`, `STATUS`).
|
|
183
|
+
if lowered in _GENERIC_WORDS and word != entity.name:
|
|
184
|
+
continue
|
|
185
|
+
direct.setdefault(entity.key, entity)
|
|
186
|
+
if "." in word:
|
|
187
|
+
for entity in _dotted_fields(project, word):
|
|
188
|
+
direct.setdefault(entity.key, entity)
|
|
189
|
+
block_names = {e.name.lower() for e in direct.values() if e.kind == "block"}
|
|
190
|
+
context_blocks, context_modules = _context(project, list(direct.values()))
|
|
191
|
+
|
|
192
|
+
# A kind word lists that kind in the blocks the question is about; with no such
|
|
193
|
+
# block, only when the whole project has a few of them (its one clock, its resets).
|
|
194
|
+
listed: dict[str, EntityBase] = {}
|
|
195
|
+
for kind in kinds:
|
|
196
|
+
members = [
|
|
197
|
+
e
|
|
198
|
+
for e in sorted(project.model.by_kind(kind), key=lambda e: e.key)
|
|
199
|
+
if project.visible(e) and (not context_blocks or project.blocks_of(e) & context_blocks)
|
|
200
|
+
]
|
|
201
|
+
if context_blocks or len(members) <= _MAX_PER_KIND_LISTING:
|
|
202
|
+
for entity in members[:_MAX_PER_KIND_LISTING]:
|
|
203
|
+
listed.setdefault(entity.key, entity)
|
|
204
|
+
|
|
205
|
+
scored: list[tuple[int, str, EntityBase]] = []
|
|
206
|
+
for key, entity in {**listed, **direct}.items():
|
|
207
|
+
kind_named = entity.kind in kinds
|
|
208
|
+
named = key in direct and (
|
|
209
|
+
entity.kind == "block" or entity.name.lower() not in block_names or kind_named
|
|
210
|
+
)
|
|
211
|
+
score = _KIND_PRIORITY.get(entity.kind, 1)
|
|
212
|
+
if named:
|
|
213
|
+
score += _DIRECT_BONUS
|
|
214
|
+
if kind_named and (named or context_blocks):
|
|
215
|
+
score += _KIND_WORD_BONUS
|
|
216
|
+
score += _context_adjustment(project, entity, context_blocks, context_modules)
|
|
217
|
+
scored.append((score, key, entity))
|
|
218
|
+
scored.sort(key=lambda item: (-item[0], item[1]))
|
|
219
|
+
return [_model_source(project, entity, "lookup") for _, _, entity in scored[:limit]]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _dotted_fields(project: AskProject, word: str) -> list[EntityBase]:
|
|
223
|
+
"""`CTRL.EN` (register.field) or `timer.CTRL` (block.register) as model entities."""
|
|
224
|
+
parts = word.lower().split(".")
|
|
225
|
+
found = []
|
|
226
|
+
for entity in project.model.entities.values():
|
|
227
|
+
if entity.kind not in ("field", "register") or not project.visible(entity):
|
|
228
|
+
continue
|
|
229
|
+
key_parts = entity.key.partition(":")[2].lower().split(".")
|
|
230
|
+
if key_parts[-len(parts) :] == parts:
|
|
231
|
+
found.append(entity)
|
|
232
|
+
return found
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _context(project: AskProject, direct: list[EntityBase]) -> tuple[set[str], set[str]]:
|
|
236
|
+
"""The blocks and modules the question is about.
|
|
237
|
+
|
|
238
|
+
The blocks and modules it names; if it names none, the blocks of the registers,
|
|
239
|
+
fields, requirements and memory regions it names.
|
|
240
|
+
"""
|
|
241
|
+
modules = {e.key for e in direct if e.kind == "module"}
|
|
242
|
+
blocks: set[str] = set()
|
|
243
|
+
for entity in direct:
|
|
244
|
+
if entity.kind in ("block", "module"):
|
|
245
|
+
blocks |= project.blocks_of(entity)
|
|
246
|
+
if not blocks:
|
|
247
|
+
for entity in direct:
|
|
248
|
+
if entity.kind in ("register", "field", "requirement", "memory_region"):
|
|
249
|
+
blocks |= project.blocks_of(entity)
|
|
250
|
+
return blocks, modules
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _context_adjustment(
|
|
254
|
+
project: AskProject,
|
|
255
|
+
entity: EntityBase,
|
|
256
|
+
context_blocks: set[str],
|
|
257
|
+
context_modules: set[str],
|
|
258
|
+
) -> int:
|
|
259
|
+
if entity.kind in ("block", "project"):
|
|
260
|
+
return 0
|
|
261
|
+
module = project.module_of(entity)
|
|
262
|
+
blocks = project.blocks_of(entity)
|
|
263
|
+
if context_modules and module is not None:
|
|
264
|
+
in_context = module in context_modules
|
|
265
|
+
elif context_blocks and blocks:
|
|
266
|
+
in_context = bool(blocks & context_blocks)
|
|
267
|
+
else:
|
|
268
|
+
return 0
|
|
269
|
+
return _CONTEXT_BONUS if in_context else -_OFF_CONTEXT_PENALTY
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _kinds_named(words: list[str]) -> list[str]:
|
|
273
|
+
"""The entity kinds the question names ("registers", "open items", "memory map")."""
|
|
274
|
+
lowered = [w.lower() for w in words]
|
|
275
|
+
kinds: list[str] = []
|
|
276
|
+
for index, word in enumerate(lowered):
|
|
277
|
+
following = lowered[index + 1] if index + 1 < len(lowered) else ""
|
|
278
|
+
pair = f"{word} {following}"
|
|
279
|
+
named = next((k for p, k in _PHRASE_KINDS if pair.startswith(p)), None)
|
|
280
|
+
if named is None:
|
|
281
|
+
if word == "reset" and following.startswith("value"):
|
|
282
|
+
continue # "reset value" asks about a register, not about a reset
|
|
283
|
+
named = _KIND_WORDS.get(word)
|
|
284
|
+
if named is not None and named not in kinds:
|
|
285
|
+
kinds.append(named)
|
|
286
|
+
return kinds
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _model_source(project: AskProject, entity: EntityBase, origin: str) -> AskSource:
|
|
290
|
+
return AskSource(
|
|
291
|
+
citation=f"model:{entity.key}",
|
|
292
|
+
kind="model",
|
|
293
|
+
text=project.summary(entity),
|
|
294
|
+
origin=origin, # type: ignore[arg-type]
|
|
295
|
+
defined_at=project.defined_at(entity),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# --- full-text search -------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _search_sources(project: AskProject, query: str, limit: int) -> list[AskSource]:
|
|
303
|
+
try:
|
|
304
|
+
hits = project.store.search(query, limit=limit * 2)
|
|
305
|
+
except sqlite3.Error:
|
|
306
|
+
return [] # a query the FTS5 parser still refuses: no search result, never an error
|
|
307
|
+
sources: list[AskSource] = []
|
|
308
|
+
for hit in hits:
|
|
309
|
+
if hit.key is not None:
|
|
310
|
+
entity = project.get(hit.key)
|
|
311
|
+
if entity is not None:
|
|
312
|
+
sources.append(_model_source(project, entity, "search"))
|
|
313
|
+
elif hit.file is not None and hit.line is not None:
|
|
314
|
+
if project.labels.label_for(hit.file) == "nda":
|
|
315
|
+
continue
|
|
316
|
+
sources.append(_document_source(project, hit.file, hit.line, hit.text))
|
|
317
|
+
return sources
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _document_source(project: AskProject, path: str, line: int, text: str) -> AskSource:
|
|
321
|
+
around = project.docs.lines(path, max(1, line - CONTEXT_LINES), line + CONTEXT_LINES)
|
|
322
|
+
return AskSource(
|
|
323
|
+
citation=f"{path}:{line}",
|
|
324
|
+
kind="document",
|
|
325
|
+
text=text,
|
|
326
|
+
origin="search",
|
|
327
|
+
context=tuple(DocLine(line=n, text=t) for n, t in around if n != line),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
__all__ = ["CONTEXT_LINES", "DEFAULT_LIMIT", "MAX_LIMIT", "ask_context", "retrieve"]
|