code-atlas-forge 1.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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Context pack engine — seleção determinística sob budget (lf-0040).
|
|
2
|
+
|
|
3
|
+
Pipeline: resolver snapshot → candidatos (símbolos diretos via
|
|
4
|
+
structural+lexical, vizinhos via graph, arquivos, dependências,
|
|
5
|
+
evidências) → dedup por identidade → prioridade → encaixe no budget
|
|
6
|
+
com reservas → indicadores explícitos de truncamento.
|
|
7
|
+
|
|
8
|
+
Nenhum item é gerado por LLM: todo corpo vem de fatos persistidos
|
|
9
|
+
(qualified_name, signature, docstring, path, arestas) ou da query.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import dataclasses
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
|
|
18
|
+
from code_atlas.context.contracts import (
|
|
19
|
+
PACK_SCHEMA_VERSION,
|
|
20
|
+
PRIORITY_COMPLEMENT,
|
|
21
|
+
PRIORITY_CONTRACT,
|
|
22
|
+
PRIORITY_DEP,
|
|
23
|
+
PRIORITY_DIRECT,
|
|
24
|
+
Budget,
|
|
25
|
+
ContextItem,
|
|
26
|
+
ContextPack,
|
|
27
|
+
ContextRequest,
|
|
28
|
+
DomainPackRequest,
|
|
29
|
+
DomainSectionFactory,
|
|
30
|
+
ExpansionRef,
|
|
31
|
+
PackDiagnostics,
|
|
32
|
+
PackEvidence,
|
|
33
|
+
PackManifest,
|
|
34
|
+
)
|
|
35
|
+
from code_atlas.context.tokenizer import HeuristicTokenizer, TokenCounter
|
|
36
|
+
from code_atlas.search.contracts import SearchRequest
|
|
37
|
+
from code_atlas.search.engine import SearchEngine
|
|
38
|
+
from code_atlas.search.graph import GraphRetrieval, TraversalLimits
|
|
39
|
+
from code_atlas.search.intent import QueryIntent, classify
|
|
40
|
+
from code_atlas.search.structural import SearchFilters, StructuralSearch
|
|
41
|
+
|
|
42
|
+
# Reservas do envelope (fração do limite) — metadados+evidências+diags.
|
|
43
|
+
_RESERVE_META = 0.05
|
|
44
|
+
_MAX_BUDGET = 1_000_000 # clamp — budget absurdo não infla reservas
|
|
45
|
+
_RESERVE_EVID = 0.10
|
|
46
|
+
_RESERVE_DIAG = 0.05
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ContextEngine:
|
|
50
|
+
"""Constrói ``ContextPack`` determinístico sobre a conexão."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self, conn: sqlite3.Connection, counter: TokenCounter | None = None
|
|
54
|
+
) -> None:
|
|
55
|
+
self._conn = conn
|
|
56
|
+
self._structural = StructuralSearch(conn)
|
|
57
|
+
self._graph = GraphRetrieval(conn)
|
|
58
|
+
self._counter = counter or HeuristicTokenizer()
|
|
59
|
+
# lf-0052: hybrid retrieval é a fonte primária de candidatos
|
|
60
|
+
# (construção lazy — nem todo consumidor constrói packs).
|
|
61
|
+
self._search: SearchEngine | None = None
|
|
62
|
+
self._pack_cache: dict[tuple[object, ...], ContextPack] = {}
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def _engine(self) -> SearchEngine: # lazy, evita import circular
|
|
66
|
+
if self._search is None:
|
|
67
|
+
self._search = SearchEngine(self._conn)
|
|
68
|
+
return self._search
|
|
69
|
+
|
|
70
|
+
# -------------------------------------------------------- público
|
|
71
|
+
def build(self, req: ContextRequest) -> ContextPack:
|
|
72
|
+
diags: list[str] = []
|
|
73
|
+
repo_id = self._structural.repo_for_root(req.repository_root)
|
|
74
|
+
if repo_id is None:
|
|
75
|
+
return self._empty(req, f"repositório não indexado: "
|
|
76
|
+
f"{req.repository_root}")
|
|
77
|
+
snap, err = self._structural.resolve_snapshot(
|
|
78
|
+
repo_id, req.snapshot_id)
|
|
79
|
+
if snap is None or snap.id is None:
|
|
80
|
+
return self._empty(req, err or "nenhum snapshot READY")
|
|
81
|
+
sid = int(snap.id or 0)
|
|
82
|
+
|
|
83
|
+
budget = self._budget(req)
|
|
84
|
+
ck = (sid, req.task, req.level, budget.limit, req.max_symbols)
|
|
85
|
+
cached = self._pack_cache.get(ck)
|
|
86
|
+
if cached is not None:
|
|
87
|
+
return cached
|
|
88
|
+
candidates = self._candidates(req, sid, diags)
|
|
89
|
+
items = self._dedup(self._estimate(candidates))
|
|
90
|
+
items.sort(key=lambda i: (i.priority, i.item_id))
|
|
91
|
+
selected, omitted = self._fit(items, budget.available_items)
|
|
92
|
+
essentials_cost = sum(
|
|
93
|
+
i.est_tokens for i in items if i.priority <= PRIORITY_DIRECT)
|
|
94
|
+
insufficient = len(selected) < len(
|
|
95
|
+
[i for i in items if i.priority <= PRIORITY_DIRECT])
|
|
96
|
+
pack = self._enforce_payload_budget(
|
|
97
|
+
req, sid, budget, selected, omitted, essentials_cost,
|
|
98
|
+
insufficient, diags, str(snap.status))
|
|
99
|
+
# cache por snapshot+tarefa — pack é imutável; FIFO 32 entradas
|
|
100
|
+
if len(self._pack_cache) >= 32:
|
|
101
|
+
self._pack_cache.pop(next(iter(self._pack_cache)))
|
|
102
|
+
self._pack_cache[ck] = pack
|
|
103
|
+
return pack
|
|
104
|
+
|
|
105
|
+
def _enforce_payload_budget(
|
|
106
|
+
self, req: ContextRequest | DomainPackRequest, sid: int,
|
|
107
|
+
budget: Budget,
|
|
108
|
+
selected: list[ContextItem], omitted: list[ContextItem],
|
|
109
|
+
essentials_cost: int, insufficient: bool,
|
|
110
|
+
diags: list[str], snap_status: str,
|
|
111
|
+
*, domain_factory: DomainSectionFactory | None = None,
|
|
112
|
+
) -> ContextPack:
|
|
113
|
+
"""lf-0053: o orçamento cobre o payload SERIALIZADO (envelope +
|
|
114
|
+
itens + metadados + diagnósticos), não só os corpos. Se estoura,
|
|
115
|
+
despeja itens de menor prioridade para ``omitted`` até caber.
|
|
116
|
+
|
|
117
|
+
lf-0121: ``domain_factory`` recebe os selecionados DE CADA
|
|
118
|
+
iteração — a seção de domínio é recriada junto com o envelope,
|
|
119
|
+
então o custo dela também é contado no payload."""
|
|
120
|
+
from code_atlas.context.renderers import render_json
|
|
121
|
+
sel, omit = list(selected), list(omitted)
|
|
122
|
+
enforced = False
|
|
123
|
+
while True:
|
|
124
|
+
pack = self._pack(req, sid, budget, sel, omit,
|
|
125
|
+
essentials_cost, insufficient,
|
|
126
|
+
diags, snap_status,
|
|
127
|
+
domain_factory=domain_factory)
|
|
128
|
+
est = self._counter.count(render_json(pack))
|
|
129
|
+
if est <= budget.limit or not sel:
|
|
130
|
+
if enforced:
|
|
131
|
+
diags = [*diags, "payload-budget-enforced"]
|
|
132
|
+
pack = self._pack(req, sid, budget, sel, omit,
|
|
133
|
+
essentials_cost, insufficient,
|
|
134
|
+
diags, snap_status,
|
|
135
|
+
domain_factory=domain_factory)
|
|
136
|
+
est = self._counter.count(render_json(pack))
|
|
137
|
+
if est > budget.limit:
|
|
138
|
+
pack = self._pack(req, sid, budget, sel, omit,
|
|
139
|
+
essentials_cost, True,
|
|
140
|
+
[*diags, "payload-over-budget"],
|
|
141
|
+
snap_status,
|
|
142
|
+
domain_factory=domain_factory)
|
|
143
|
+
est = self._counter.count(render_json(pack))
|
|
144
|
+
# grava a estimativa final no manifest
|
|
145
|
+
return dataclasses.replace(
|
|
146
|
+
pack, manifest=dataclasses.replace(
|
|
147
|
+
pack.manifest, tokens_payload=est))
|
|
148
|
+
# despeja o item de MENOR prioridade (maior número; desempate
|
|
149
|
+
# pelo item_id mais alto) — ordenação estável reversa
|
|
150
|
+
sel.sort(key=lambda i: (i.priority, i.item_id),
|
|
151
|
+
reverse=True)
|
|
152
|
+
omit.append(sel.pop(0))
|
|
153
|
+
sel.sort(key=lambda i: (i.priority, i.item_id))
|
|
154
|
+
if not insufficient and any(
|
|
155
|
+
i.priority <= PRIORITY_DIRECT for i in omit):
|
|
156
|
+
insufficient = True
|
|
157
|
+
enforced = True
|
|
158
|
+
|
|
159
|
+
# -------------------------------------------------------- internals
|
|
160
|
+
def _budget(self, req: ContextRequest | DomainPackRequest) -> Budget:
|
|
161
|
+
limit = max(64, min(req.budget_tokens, _MAX_BUDGET))
|
|
162
|
+
meta = int(limit * _RESERVE_META)
|
|
163
|
+
evid = int(limit * _RESERVE_EVID)
|
|
164
|
+
diag = int(limit * _RESERVE_DIAG)
|
|
165
|
+
return Budget(
|
|
166
|
+
limit=limit, reserved_meta=meta, reserved_evidence=evid,
|
|
167
|
+
reserved_diagnostics=diag,
|
|
168
|
+
available_items=max(32, limit - meta - evid - diag),
|
|
169
|
+
estimator=self._counter.counter_id)
|
|
170
|
+
|
|
171
|
+
def _candidates(
|
|
172
|
+
self, req: ContextRequest, sid: int, diags: list[str]
|
|
173
|
+
) -> list[ContextItem]:
|
|
174
|
+
"""lf-0052: hybrid retrieval é a fonte primária de candidatos
|
|
175
|
+
(structural+lexical+graph+vector fusionados via RRF). Fallback
|
|
176
|
+
declarado: structural substring quando hybrid não retorna nada
|
|
177
|
+
(ex.: FTS indisponível e sem match estrutural interno)."""
|
|
178
|
+
items: list[ContextItem] = []
|
|
179
|
+
intent = classify(req.task)
|
|
180
|
+
hits = self._engine.search(SearchRequest(
|
|
181
|
+
query=req.task, strategy="hybrid",
|
|
182
|
+
repository_root=req.repository_root, snapshot_id=sid,
|
|
183
|
+
limit=req.max_symbols)).items
|
|
184
|
+
source = "hybrid"
|
|
185
|
+
if not hits:
|
|
186
|
+
# fallback tipado — structural substring (comportamento v1)
|
|
187
|
+
source = "structural-fallback"
|
|
188
|
+
diags.append("candidate-source:structural-fallback")
|
|
189
|
+
repo_id = self._structural.repo_for_root(req.repository_root)
|
|
190
|
+
page = self._structural.search_symbols(
|
|
191
|
+
repo_id or 0, sid, req.task, SearchFilters(),
|
|
192
|
+
req.max_symbols, 0)
|
|
193
|
+
from code_atlas.search.contracts import SearchHit
|
|
194
|
+
hits = tuple(SearchHit(
|
|
195
|
+
occurrence_id=int(s.get("occurrence_id", 0) or 0),
|
|
196
|
+
qualified_name=str(s["qualified_name"]),
|
|
197
|
+
kind=str(s["kind"]), language=str(s["language"]),
|
|
198
|
+
path=str(s.get("path", "")), span=None, score=0.0,
|
|
199
|
+
strategy="structural",
|
|
200
|
+
matched_fields=("qualified_name",))
|
|
201
|
+
for s in page.items)
|
|
202
|
+
if not hits:
|
|
203
|
+
diags.append("candidate-source:empty")
|
|
204
|
+
occ = self._occ_rows(sid, [
|
|
205
|
+
h.occurrence_id for h in hits if h.occurrence_id],
|
|
206
|
+
by="occurrence")
|
|
207
|
+
for h in hits:
|
|
208
|
+
qn = h.qualified_name
|
|
209
|
+
row = occ.get(h.occurrence_id, {})
|
|
210
|
+
sig = str(row.get("signature") or "")
|
|
211
|
+
doc = str(row.get("docstring") or "")
|
|
212
|
+
body = "\n".join(p for p in (qn, sig, doc) if p)
|
|
213
|
+
has_contract = bool(sig or doc)
|
|
214
|
+
items.append(ContextItem(
|
|
215
|
+
item_id=f"sym:{qn}", kind="symbol",
|
|
216
|
+
priority=(PRIORITY_CONTRACT if has_contract
|
|
217
|
+
else PRIORITY_DIRECT),
|
|
218
|
+
title=qn, body=body, est_tokens=0,
|
|
219
|
+
symbol_key=qn, path=h.path or str(
|
|
220
|
+
row.get("relative_path", "")),
|
|
221
|
+
evidence=(
|
|
222
|
+
PackEvidence(kind="occurrence",
|
|
223
|
+
ref=str(h.occurrence_id)),
|
|
224
|
+
PackEvidence(kind="ranking",
|
|
225
|
+
ref=f"{source}:{h.score:.6f}"),
|
|
226
|
+
PackEvidence(kind="intent",
|
|
227
|
+
ref=intent.task_type)),
|
|
228
|
+
expandable=True))
|
|
229
|
+
depth = {"orientation": 0, "focus": 1, "deep": 3}[req.level]
|
|
230
|
+
seed_ids = [h.occurrence_id for h in hits if h.occurrence_id]
|
|
231
|
+
if depth:
|
|
232
|
+
items.extend(self._neighbor_items(
|
|
233
|
+
sid, seed_ids, depth, direction=intent.direction))
|
|
234
|
+
for p in sorted({i.path for i in items if i.path}):
|
|
235
|
+
items.append(ContextItem(
|
|
236
|
+
item_id=f"file:{p}", kind="file",
|
|
237
|
+
priority=PRIORITY_COMPLEMENT, title=p, body=p,
|
|
238
|
+
est_tokens=0, path=p))
|
|
239
|
+
items.extend(self._dependency_items(sid, seed_ids, intent))
|
|
240
|
+
return items
|
|
241
|
+
|
|
242
|
+
def _occ_rows(
|
|
243
|
+
self, sid: int, ids: list[int], *, by: str = "symbol"
|
|
244
|
+
) -> dict[int, dict[str, object]]:
|
|
245
|
+
"""Ocorrências + signature/docstring do FTS — batched via
|
|
246
|
+
json_each, sem N+1. ``by="symbol"`` chaveia por
|
|
247
|
+
``logical_symbol_id``; ``by="occurrence"`` por ``o.id``."""
|
|
248
|
+
if not ids:
|
|
249
|
+
return {}
|
|
250
|
+
key_col = ("o.logical_symbol_id" if by == "symbol" else "o.id")
|
|
251
|
+
sql = (
|
|
252
|
+
"SELECT o.id, o.logical_symbol_id, s.qualified_name,"
|
|
253
|
+
" sf.relative_path, f.signature, f.docstring"
|
|
254
|
+
" FROM symbol_occurrences o"
|
|
255
|
+
" JOIN symbols s ON s.id = o.logical_symbol_id"
|
|
256
|
+
" JOIN source_files sf ON sf.id = o.source_file_id"
|
|
257
|
+
" LEFT JOIN fts_occurrences f ON f.rowid = o.id"
|
|
258
|
+
f" WHERE o.snapshot_id = ? AND {key_col} IN"
|
|
259
|
+
" (SELECT value FROM json_each(?))"
|
|
260
|
+
" ORDER BY o.id")
|
|
261
|
+
rows = self._conn.execute(sql, (sid, json.dumps(ids))).fetchall()
|
|
262
|
+
out: dict[int, dict[str, object]] = {}
|
|
263
|
+
kf = "logical_symbol_id" if by == "symbol" else "id"
|
|
264
|
+
for r in rows:
|
|
265
|
+
k = int(r[kf])
|
|
266
|
+
if k not in out:
|
|
267
|
+
out[k] = dict(r)
|
|
268
|
+
return out
|
|
269
|
+
|
|
270
|
+
def _neighbor_items(
|
|
271
|
+
self, sid: int, seed_ids: list[int], depth: int,
|
|
272
|
+
direction: str = "both",
|
|
273
|
+
) -> list[ContextItem]:
|
|
274
|
+
out: list[ContextItem] = []
|
|
275
|
+
seed_ids = seed_ids[:20]
|
|
276
|
+
if not seed_ids:
|
|
277
|
+
return out
|
|
278
|
+
hits, _stats = self._graph.traverse(
|
|
279
|
+
sid, seed_ids, direction=direction,
|
|
280
|
+
limits=TraversalLimits(
|
|
281
|
+
depth=depth, max_nodes=40, max_results=40))
|
|
282
|
+
for h in hits:
|
|
283
|
+
out.append(ContextItem(
|
|
284
|
+
item_id=f"nb:{h.qualified_name}",
|
|
285
|
+
kind="symbol", priority=PRIORITY_COMPLEMENT,
|
|
286
|
+
title=h.qualified_name, body=h.qualified_name,
|
|
287
|
+
est_tokens=0, symbol_key=h.qualified_name,
|
|
288
|
+
path=h.path, expandable=True))
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
def _dependency_items(
|
|
292
|
+
self, sid: int, seed_ids: list[int],
|
|
293
|
+
intent: QueryIntent | None = None,
|
|
294
|
+
) -> list[ContextItem]:
|
|
295
|
+
# lf-0052: intents de investigação sobem dependências comprovadas
|
|
296
|
+
# para prioridade de contrato (call flow/deps são o foco).
|
|
297
|
+
dep_priority = PRIORITY_DEP
|
|
298
|
+
if intent is not None and intent.task_type in (
|
|
299
|
+
"find-consumers", "explain-dependencies",
|
|
300
|
+
"investigate-failure"):
|
|
301
|
+
dep_priority = PRIORITY_CONTRACT
|
|
302
|
+
if not seed_ids:
|
|
303
|
+
return []
|
|
304
|
+
rows = self._conn.execute(
|
|
305
|
+
"SELECT sa.qualified_name AS src, sb.qualified_name AS dst,"
|
|
306
|
+
" r.type AS edge_kind FROM relations r"
|
|
307
|
+
" JOIN symbol_occurrences a ON a.id = r.from_id"
|
|
308
|
+
" LEFT JOIN symbol_occurrences b ON b.id = r.to_id"
|
|
309
|
+
" JOIN symbols sa ON sa.id = a.logical_symbol_id"
|
|
310
|
+
" LEFT JOIN symbols sb ON sb.id = b.logical_symbol_id"
|
|
311
|
+
" WHERE r.snapshot_id = ? AND (r.from_id IN "
|
|
312
|
+
"(SELECT value FROM json_each(?)) OR r.to_id IN "
|
|
313
|
+
"(SELECT value FROM json_each(?))) LIMIT 100",
|
|
314
|
+
(sid, json.dumps(seed_ids), json.dumps(seed_ids)),
|
|
315
|
+
).fetchall()
|
|
316
|
+
return [ContextItem(
|
|
317
|
+
item_id=f"dep:{r['src']}->{r['edge_kind']}->{r['dst']}",
|
|
318
|
+
kind="dependency", priority=dep_priority,
|
|
319
|
+
title=f"{r['src']} -[{r['edge_kind']}]-> {r['dst']}",
|
|
320
|
+
body=f"{r['src']} {r['edge_kind']} {r['dst']}",
|
|
321
|
+
est_tokens=0,
|
|
322
|
+
evidence=(PackEvidence(kind="relation",
|
|
323
|
+
ref=str(r["edge_kind"])),))
|
|
324
|
+
for r in rows if r["dst"]]
|
|
325
|
+
|
|
326
|
+
def _estimate(self, items: list[ContextItem]) -> list[ContextItem]:
|
|
327
|
+
return [i if i.est_tokens else ContextItem(
|
|
328
|
+
item_id=i.item_id, kind=i.kind, priority=i.priority,
|
|
329
|
+
title=i.title, body=i.body,
|
|
330
|
+
est_tokens=self._counter.count(i.body),
|
|
331
|
+
symbol_key=i.symbol_key, path=i.path,
|
|
332
|
+
evidence=i.evidence, expandable=i.expandable)
|
|
333
|
+
for i in items]
|
|
334
|
+
|
|
335
|
+
def _dedup(self, items: list[ContextItem]) -> list[ContextItem]:
|
|
336
|
+
seen: set[str] = set()
|
|
337
|
+
out: list[ContextItem] = []
|
|
338
|
+
for i in items:
|
|
339
|
+
key = f"{i.kind}:{i.item_id}"
|
|
340
|
+
if key in seen:
|
|
341
|
+
continue
|
|
342
|
+
seen.add(key)
|
|
343
|
+
out.append(i)
|
|
344
|
+
return out
|
|
345
|
+
|
|
346
|
+
def _fit(
|
|
347
|
+
self, items: list[ContextItem], available: int
|
|
348
|
+
) -> tuple[list[ContextItem], list[ContextItem]]:
|
|
349
|
+
sel: list[ContextItem] = []
|
|
350
|
+
omit: list[ContextItem] = []
|
|
351
|
+
used = 0
|
|
352
|
+
for i in items:
|
|
353
|
+
if used + i.est_tokens <= available:
|
|
354
|
+
sel.append(i)
|
|
355
|
+
used += i.est_tokens
|
|
356
|
+
else:
|
|
357
|
+
omit.append(i)
|
|
358
|
+
return sel, omit
|
|
359
|
+
|
|
360
|
+
def _pack(
|
|
361
|
+
self, req: ContextRequest | DomainPackRequest, sid: int,
|
|
362
|
+
budget: Budget,
|
|
363
|
+
selected: list[ContextItem], omitted: list[ContextItem],
|
|
364
|
+
essentials_cost: int, insufficient: bool,
|
|
365
|
+
diags: list[str], snap_status: str,
|
|
366
|
+
*, domain_factory: DomainSectionFactory | None = None,
|
|
367
|
+
) -> ContextPack:
|
|
368
|
+
codes: list[str] = list(diags)
|
|
369
|
+
if omitted:
|
|
370
|
+
codes.append("pack-truncated")
|
|
371
|
+
if insufficient:
|
|
372
|
+
codes.extend(("insufficient-budget",
|
|
373
|
+
"mandatory-context-omitted"))
|
|
374
|
+
# lf-0121: a fábrica produz a seção de domínio + refs extras a
|
|
375
|
+
# partir dos selecionados desta iteração — contada no payload.
|
|
376
|
+
part = domain_factory(tuple(selected)) if domain_factory else None
|
|
377
|
+
expansion: list[ExpansionRef] = [
|
|
378
|
+
ExpansionRef(ref_id=i.item_id, kind=i.kind,
|
|
379
|
+
reason="omitido por budget ou nível")
|
|
380
|
+
for i in omitted if i.expandable]
|
|
381
|
+
unresolved: tuple[str, ...] = ()
|
|
382
|
+
ranking = (
|
|
383
|
+
"prioridade: evidências > diretos > contratos > deps > "
|
|
384
|
+
"snippets > complementar > transitivos; dentro da "
|
|
385
|
+
"classe, item_id estável")
|
|
386
|
+
if part is not None:
|
|
387
|
+
expansion.extend(part.extra_expansion_refs)
|
|
388
|
+
unresolved = part.extra_unresolved
|
|
389
|
+
if part.ranking_explanation:
|
|
390
|
+
ranking = part.ranking_explanation
|
|
391
|
+
manifest = PackManifest(
|
|
392
|
+
schema_version=PACK_SCHEMA_VERSION,
|
|
393
|
+
repository=req.repository_root, snapshot_id=sid,
|
|
394
|
+
level=req.level, estimator=budget.estimator,
|
|
395
|
+
tokens_estimated=True,
|
|
396
|
+
selected=len(selected), omitted=len(omitted),
|
|
397
|
+
truncated=bool(omitted),
|
|
398
|
+
freshness=f"snapshot:{sid} status:{snap_status}",
|
|
399
|
+
ranking_explanation=ranking)
|
|
400
|
+
diagnostics = PackDiagnostics(
|
|
401
|
+
codes=tuple(codes), messages=tuple(diags),
|
|
402
|
+
insufficient_budget=insufficient,
|
|
403
|
+
mandatory_context_omitted=insufficient,
|
|
404
|
+
suggested_minimum_budget=(
|
|
405
|
+
essentials_cost + budget.limit - budget.available_items
|
|
406
|
+
if insufficient else 0))
|
|
407
|
+
return ContextPack(
|
|
408
|
+
manifest=manifest, budget=budget,
|
|
409
|
+
items=tuple(selected),
|
|
410
|
+
selected_symbols=tuple(
|
|
411
|
+
i.symbol_key for i in selected
|
|
412
|
+
if i.kind == "symbol" and i.symbol_key),
|
|
413
|
+
relevant_files=tuple(sorted(
|
|
414
|
+
{i.path for i in selected if i.path})),
|
|
415
|
+
dependencies=tuple(
|
|
416
|
+
ev for i in selected if i.kind == "dependency"
|
|
417
|
+
for ev in i.evidence),
|
|
418
|
+
unresolved_refs=unresolved,
|
|
419
|
+
expansion_refs=tuple(expansion),
|
|
420
|
+
diagnostics=diagnostics,
|
|
421
|
+
domain=part.section if part is not None else None)
|
|
422
|
+
|
|
423
|
+
def _empty(
|
|
424
|
+
self, req: ContextRequest | DomainPackRequest, msg: str
|
|
425
|
+
) -> ContextPack:
|
|
426
|
+
budget = self._budget(req)
|
|
427
|
+
manifest = PackManifest(
|
|
428
|
+
schema_version=PACK_SCHEMA_VERSION,
|
|
429
|
+
repository=req.repository_root, snapshot_id=0,
|
|
430
|
+
level=req.level, estimator=budget.estimator,
|
|
431
|
+
tokens_estimated=True, selected=0, omitted=0,
|
|
432
|
+
truncated=False, freshness="none",
|
|
433
|
+
ranking_explanation="vazio — resolução falhou")
|
|
434
|
+
return ContextPack(
|
|
435
|
+
manifest=manifest, budget=budget, items=(),
|
|
436
|
+
selected_symbols=(), relevant_files=(), dependencies=(),
|
|
437
|
+
unresolved_refs=(), expansion_refs=(),
|
|
438
|
+
diagnostics=PackDiagnostics(
|
|
439
|
+
codes=("resolve-failed",), messages=(msg,),
|
|
440
|
+
insufficient_budget=False,
|
|
441
|
+
mandatory_context_omitted=False,
|
|
442
|
+
suggested_minimum_budget=0))
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Expansão progressiva de contexto (lf-0041 / Cycle 4 Ph8).
|
|
2
|
+
|
|
3
|
+
Autocontida: o caller passa o ``snapshot_id`` do pack original + os
|
|
4
|
+
``item_ids`` já entregues — nenhum estado global. Se o snapshot não é
|
|
5
|
+
mais READY, erro explícito; NUNCA troca de revisão silenciosamente.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from code_atlas.context.contracts import (
|
|
15
|
+
PRIORITY_DEP,
|
|
16
|
+
ContextItem,
|
|
17
|
+
ExpansionRef,
|
|
18
|
+
PackEvidence,
|
|
19
|
+
)
|
|
20
|
+
from code_atlas.context.tokenizer import HeuristicTokenizer, TokenCounter
|
|
21
|
+
from code_atlas.search.graph import GraphRetrieval, TraversalLimits
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ExpansionResult:
|
|
26
|
+
"""Novos itens + dedup acumulada + refs restantes."""
|
|
27
|
+
|
|
28
|
+
snapshot_id: int
|
|
29
|
+
added: tuple[ContextItem, ...]
|
|
30
|
+
already_delivered: tuple[str, ...]
|
|
31
|
+
remaining_refs: tuple[ExpansionRef, ...]
|
|
32
|
+
truncated: bool
|
|
33
|
+
error: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ContextExpander:
|
|
37
|
+
"""Expande símbolo/deps/refs dentro do MESMO snapshot do pack."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self, conn: sqlite3.Connection, counter: TokenCounter | None = None
|
|
41
|
+
) -> None:
|
|
42
|
+
self._conn = conn
|
|
43
|
+
self._graph = GraphRetrieval(conn)
|
|
44
|
+
self._counter = counter or HeuristicTokenizer()
|
|
45
|
+
|
|
46
|
+
def expand(
|
|
47
|
+
self,
|
|
48
|
+
snapshot_id: int,
|
|
49
|
+
symbol: str,
|
|
50
|
+
*,
|
|
51
|
+
delivered: frozenset[str] = frozenset(),
|
|
52
|
+
budget_tokens: int = 4000,
|
|
53
|
+
depth: int = 2,
|
|
54
|
+
) -> ExpansionResult:
|
|
55
|
+
"""Dependências+referências do símbolo no snapshot — dedup contra
|
|
56
|
+
``delivered``; respeita a mesma disciplina de budget."""
|
|
57
|
+
if not self._snapshot_ready(snapshot_id):
|
|
58
|
+
return ExpansionResult(
|
|
59
|
+
snapshot_id=snapshot_id, added=(), already_delivered=(),
|
|
60
|
+
remaining_refs=(), truncated=False,
|
|
61
|
+
error=f"snapshot {snapshot_id} indisponível (não READY)")
|
|
62
|
+
occ = self._occurrence_id(snapshot_id, symbol)
|
|
63
|
+
if occ is None:
|
|
64
|
+
return ExpansionResult(
|
|
65
|
+
snapshot_id=snapshot_id, added=(), already_delivered=(),
|
|
66
|
+
remaining_refs=(), truncated=False,
|
|
67
|
+
error=f"símbolo sem ocorrência no snapshot: {symbol}")
|
|
68
|
+
hits, _st = self._graph.traverse(
|
|
69
|
+
snapshot_id, [occ], direction="both",
|
|
70
|
+
limits=TraversalLimits(depth=depth, max_nodes=50,
|
|
71
|
+
max_results=50))
|
|
72
|
+
budget = max(64, budget_tokens)
|
|
73
|
+
used = added_n = 0
|
|
74
|
+
added: list[ContextItem] = []
|
|
75
|
+
dup: list[str] = []
|
|
76
|
+
remaining: list[ExpansionRef] = []
|
|
77
|
+
for h in hits:
|
|
78
|
+
iid = f"nb:{h.qualified_name}"
|
|
79
|
+
item = ContextItem(
|
|
80
|
+
item_id=iid, kind="symbol", priority=PRIORITY_DEP,
|
|
81
|
+
title=h.qualified_name,
|
|
82
|
+
body="\n".join(
|
|
83
|
+
f"{s.from_id}-[{s.relation}]->{s.to_id}"
|
|
84
|
+
for s in h.steps) or h.qualified_name,
|
|
85
|
+
est_tokens=0, symbol_key=h.qualified_name, path=h.path,
|
|
86
|
+
evidence=tuple(PackEvidence(
|
|
87
|
+
kind="relation", ref=s.relation) for s in h.steps),
|
|
88
|
+
expandable=True)
|
|
89
|
+
item = ContextItem(
|
|
90
|
+
**{**item.__dict__,
|
|
91
|
+
"est_tokens": self._counter.count(item.body)})
|
|
92
|
+
if iid in delivered:
|
|
93
|
+
dup.append(iid)
|
|
94
|
+
continue
|
|
95
|
+
if used + item.est_tokens <= budget:
|
|
96
|
+
added.append(item)
|
|
97
|
+
used += item.est_tokens
|
|
98
|
+
added_n += 1
|
|
99
|
+
else:
|
|
100
|
+
remaining.append(ExpansionRef(
|
|
101
|
+
ref_id=iid, kind="symbol",
|
|
102
|
+
reason="fora do budget da expansão"))
|
|
103
|
+
return ExpansionResult(
|
|
104
|
+
snapshot_id=snapshot_id, added=tuple(added),
|
|
105
|
+
already_delivered=tuple(dup),
|
|
106
|
+
remaining_refs=tuple(remaining),
|
|
107
|
+
truncated=bool(remaining))
|
|
108
|
+
|
|
109
|
+
def _snapshot_ready(self, snapshot_id: int) -> bool:
|
|
110
|
+
row = self._conn.execute(
|
|
111
|
+
"SELECT status FROM snapshots WHERE id = ?",
|
|
112
|
+
(snapshot_id,)).fetchone()
|
|
113
|
+
return bool(row) and str(row["status"]).endswith("READY")
|
|
114
|
+
|
|
115
|
+
def _occurrence_id(self, snapshot_id: int, symbol: str) -> int | None:
|
|
116
|
+
row = self._conn.execute(
|
|
117
|
+
"SELECT o.id FROM symbol_occurrences o"
|
|
118
|
+
" JOIN symbols s ON s.id = o.logical_symbol_id"
|
|
119
|
+
" WHERE o.snapshot_id = ? AND (s.qualified_name = ?"
|
|
120
|
+
" OR s.qualified_name LIKE ?) ORDER BY o.id LIMIT 1",
|
|
121
|
+
(snapshot_id, symbol, f"%.{symbol}"),
|
|
122
|
+
).fetchone()
|
|
123
|
+
return int(row["id"]) if row else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def expansion_to_json(res: ExpansionResult) -> str:
|
|
127
|
+
"""Envelope parseável do expand — schema v1."""
|
|
128
|
+
import dataclasses
|
|
129
|
+
|
|
130
|
+
return json.dumps({
|
|
131
|
+
"schema_version": 1, "renderer_version": "expand-json-v1",
|
|
132
|
+
**{k: v for k, v in dataclasses.asdict(res).items()},
|
|
133
|
+
}, sort_keys=True)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Renderers versionados do ContextPack — JSON + Markdown (lf-0040)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from code_atlas.context.contracts import ContextPack
|
|
9
|
+
|
|
10
|
+
RENDERER_VERSION_JSON = "pack-json-v1"
|
|
11
|
+
RENDERER_VERSION_MD = "pack-markdown-v1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render_json(pack: ContextPack) -> str:
|
|
15
|
+
"""Objeto único parseável com renderer_version embutida."""
|
|
16
|
+
d: dict[str, Any] = pack.to_dict()
|
|
17
|
+
d["renderer_version"] = RENDERER_VERSION_JSON
|
|
18
|
+
return json.dumps(d, sort_keys=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_markdown(pack: ContextPack) -> str:
|
|
22
|
+
"""Markdown determinístico — mesma ordem de itens do pack."""
|
|
23
|
+
m = pack.manifest
|
|
24
|
+
lines = [
|
|
25
|
+
"<!-- renderer: pack-markdown-v1 -->",
|
|
26
|
+
f"# Context Pack — snapshot {m.snapshot_id} ({m.level})",
|
|
27
|
+
"",
|
|
28
|
+
f"- budget: {pack.budget.available_items}/"
|
|
29
|
+
f"{pack.budget.limit} tokens estimados ({m.estimator})",
|
|
30
|
+
f"- selected: {m.selected} · omitted: {m.omitted} · "
|
|
31
|
+
f"truncated: {str(m.truncated).lower()}",
|
|
32
|
+
f"- freshness: {m.freshness}",
|
|
33
|
+
]
|
|
34
|
+
if pack.diagnostics.codes:
|
|
35
|
+
lines.append(
|
|
36
|
+
f"- diagnostics: {', '.join(pack.diagnostics.codes)}")
|
|
37
|
+
if pack.diagnostics.suggested_minimum_budget:
|
|
38
|
+
lines.append(
|
|
39
|
+
f"- suggested_minimum_budget: "
|
|
40
|
+
f"{pack.diagnostics.suggested_minimum_budget}")
|
|
41
|
+
lines.append("")
|
|
42
|
+
for item in pack.items:
|
|
43
|
+
lines.append(f"## {item.title}")
|
|
44
|
+
lines.append(f"_{item.kind} · prio={item.priority} · "
|
|
45
|
+
f"~{item.est_tokens} tok_")
|
|
46
|
+
lines.append("")
|
|
47
|
+
lines.append("```")
|
|
48
|
+
lines.append(item.body)
|
|
49
|
+
lines.append("```")
|
|
50
|
+
lines.append("")
|
|
51
|
+
if pack.expansion_refs:
|
|
52
|
+
lines.append("## Expandable refs")
|
|
53
|
+
for r in pack.expansion_refs:
|
|
54
|
+
lines.append(f"- `{r.ref_id}` ({r.kind}) — {r.reason}")
|
|
55
|
+
lines.append("")
|
|
56
|
+
if pack.domain is not None:
|
|
57
|
+
d = pack.domain
|
|
58
|
+
lines.append(f"## Domain — {d.status} "
|
|
59
|
+
f"(schema v{d.schema_version})")
|
|
60
|
+
if d.entity is not None:
|
|
61
|
+
e = d.entity
|
|
62
|
+
lines.append(
|
|
63
|
+
f"- {e.kind} `{e.identity_key}` — {e.qualified_name}")
|
|
64
|
+
lines.append(
|
|
65
|
+
f"- neighbors: {len(d.neighbors)}/{d.neighbors_total} "
|
|
66
|
+
f"(omitted {d.neighbors_omitted}) · "
|
|
67
|
+
f"counts: {dict(sorted(d.counts.items()))}")
|
|
68
|
+
if d.summary:
|
|
69
|
+
lines.append(f"- lineage: {d.summary}")
|
|
70
|
+
lines.append("")
|
|
71
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Estimador de tokens (lf-0040).
|
|
2
|
+
|
|
3
|
+
Heurístico por default — ``estimate`` NUNCA é contagem exata (declarado
|
|
4
|
+
em ``tokens_estimated=True`` e no id do estimador). Seam
|
|
5
|
+
``TokenCounter`` permite adapter real opt-in sem mudar o engine.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TokenCounter(Protocol):
|
|
14
|
+
"""Conta/estima tokens de um texto."""
|
|
15
|
+
|
|
16
|
+
counter_id: str
|
|
17
|
+
|
|
18
|
+
def count(self, text: str) -> int: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HeuristicTokenizer:
|
|
22
|
+
"""chars/token fixo (default 4.0) — estimativa declarada, honesta.
|
|
23
|
+
|
|
24
|
+
Não é tokenizer de nenhum modelo: superestima texto ASCII denso,
|
|
25
|
+
subestima identificadores longos — erro uniforme e documentado.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
counter_id = "heuristic-chars4"
|
|
29
|
+
|
|
30
|
+
def __init__(self, chars_per_token: float = 4.0) -> None:
|
|
31
|
+
if chars_per_token <= 0:
|
|
32
|
+
raise ValueError("chars_per_token deve ser > 0")
|
|
33
|
+
self._cpt = chars_per_token
|
|
34
|
+
self.counter_id = f"heuristic-chars{chars_per_token:g}"
|
|
35
|
+
|
|
36
|
+
def count(self, text: str) -> int:
|
|
37
|
+
return max(1, int(len(text) / self._cpt + 0.5))
|