feat(shapes): the agent judges what it wrote, at the end of the turn (milestone 439 steps 1-3)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 33s
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 33s
Recording used to be decided by machinery — the only "record it" prompt fired when a same-named copy already existed (#2664), so a first instance of a reusable piece was never asked about, and judgment arrived only through audits. Now the question is asked where the knowledge is: the end of the turn that wrote the code, of the agent that wrote it. - Write hooks keep `<sid>.written.ids` (path, kind, name) for every definition a write names; a new file adds a `file` line for its stem — a candidate in any language without a framework rule (scribe_written_append). - Stop hook scribe_shape_check.sh sends the ledger to GET /api/plugin/shape-check and blocks once, in the server's words, when anything is unjudged. Same discipline as the report check: never twice, never without a recorded check, another hook's loop left alone; the ledger is kept when the instance cannot be reached. - shape_ledger.unjudged_shapes: no row, unclassified, scoped and hook stamps are unjudged; an agent/audit/import verdict is not. A snippet recorded at the shape answers for it until the refresh stamps it canonical. - services/shape_check owns the reason text and records every outcome in app_logs (passed / blocked / judged_after_block / left_after_block). - classify_shapes(repo=…) judges a shape the ledger has not synced yet via a provisional row under a bound repo; the sync confirms it, or vanishes and revives it with the verdict intact. An unbound repo is refused. Plugin version minted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,8 @@ The accounting model (note 2786): the snippet library records CANON (small);
|
||||
the ledger accounts for EVERY extracted shape (total). These tools are how
|
||||
agents move shapes out of `unclassified` — the todo state — and how they read
|
||||
what still needs judgment. The ledger rows themselves are fed by the coverage
|
||||
refresh; these tools only ever judge what the sync has seen.
|
||||
refresh; a judgment given with `repo` may also land on a shape written this
|
||||
turn that the sync has not seen yet (milestone 439).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,7 +15,7 @@ from scribe.services import shape_ledger as shape_ledger_svc
|
||||
|
||||
|
||||
async def classify_shapes(
|
||||
project_id: int, classifications: list[dict], via: str = "agent"
|
||||
project_id: int, classifications: list[dict], via: str = "agent", repo: str = ""
|
||||
) -> dict:
|
||||
"""Record judgments for a project's code shapes — in batch, as rows.
|
||||
|
||||
@@ -52,15 +53,22 @@ async def classify_shapes(
|
||||
and get_snippet's `uses` read them.
|
||||
via: Who is judging — "agent" (default), "audit" (a sweep), or
|
||||
"import" (carrying maps recorded elsewhere).
|
||||
repo: The repo the shapes live in (its remote URL or bound key), for
|
||||
judging what you wrote THIS turn — shapes the ledger has not
|
||||
synced yet. With it, a shape no row matches is recorded as a
|
||||
provisional row under that repo and judged; the next sync
|
||||
confirms it. Must be bound to the project. Omit it to judge only
|
||||
synced rows.
|
||||
|
||||
All-or-nothing: a structural error, a missing snippet target, or no write
|
||||
access applies NOTHING. Returns {"classified": N, "unmatched": [...]} —
|
||||
unmatched names shapes no live ledger row matches (the tree may have
|
||||
moved since you listed; re-run the project's coverage refresh to re-sync).
|
||||
All-or-nothing: a structural error, a missing snippet target, an unbound
|
||||
repo, or no write access applies NOTHING. Returns {"classified": N,
|
||||
"unmatched": [...], "provisional": N?} — unmatched names shapes no live
|
||||
ledger row matches (the tree may have moved since you listed; pass `repo`
|
||||
for a shape you just wrote, or re-run the project's coverage refresh).
|
||||
"""
|
||||
uid = current_user_id()
|
||||
return await shape_ledger_svc.classify_shapes(
|
||||
uid, project_id, classifications, via=via
|
||||
uid, project_id, classifications, via=via, repo=repo
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from scribe.config import Config
|
||||
from scribe.services import plugin_context as plugin_ctx_svc
|
||||
from scribe.services import repo_bindings as repo_bindings_svc
|
||||
from scribe.services import report_check as report_check_svc
|
||||
from scribe.services import shape_check as shape_check_svc
|
||||
from scribe.services import task_claims as task_claims_svc
|
||||
from scribe.services.settings import get_admin_setting, set_setting
|
||||
|
||||
@@ -359,6 +360,71 @@ async def report_check():
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
@plugin_bp.get("/shape-check")
|
||||
@login_required
|
||||
async def shape_check():
|
||||
"""The end-of-turn question (milestone 439): what did this turn write that
|
||||
nobody has judged?
|
||||
|
||||
The client's write hooks keep a ledger of the definitions each turn wrote;
|
||||
its Stop hook sends them here. The server decides which carry no judgment
|
||||
— against the ledger and the project's recorded snippets — records the
|
||||
outcome, and for a block returns the `reason` the agent is sent back with.
|
||||
A hook blocks only on a reason it received, so only on a block that was
|
||||
recorded, and never on the stop that follows one (`phase=after`).
|
||||
|
||||
A GET like every plugin endpoint: a read-scoped key runs the plugin, and
|
||||
this records telemetry the way /report-check does. It changes no ledger
|
||||
row — the agent's own classify_shapes call does that.
|
||||
|
||||
Query:
|
||||
written (str) — newline-separated `path<TAB>kind<TAB>name` lines,
|
||||
repo-relative, kind css|sym|file.
|
||||
phase (str) — check | after. Anything else is a 400.
|
||||
repo (opt) — working repo remote, resolved like the other arms.
|
||||
project_id (opt) — explicit scope override.
|
||||
"""
|
||||
phase = (request.args.get("phase") or "check").strip()
|
||||
if phase not in shape_check_svc.PHASES:
|
||||
return jsonify({"error": f"phase must be one of {list(shape_check_svc.PHASES)}"}), 400
|
||||
written = shape_check_svc.parse_written(request.args.get("written") or "")
|
||||
project_id, repo, _unbound = await _project_scope()
|
||||
body: dict = {"status": "ok", "unjudged": []}
|
||||
if not project_id or not written:
|
||||
return jsonify(body)
|
||||
from scribe.services import access
|
||||
from scribe.services import coverage as coverage_svc
|
||||
from scribe.services import shape_ledger as shape_ledger_svc
|
||||
|
||||
if not await access.can_read_project(g.user.id, project_id):
|
||||
return jsonify(body)
|
||||
repo_key = repo_bindings_svc.normalize_repo_key(repo) if repo else ""
|
||||
unjudged = await shape_ledger_svc.unjudged_shapes(project_id, written, repo_key=repo_key)
|
||||
# A snippet recorded AT a shape is the strongest verdict there is — the
|
||||
# next refresh stamps that row canonical. Until then the ledger cannot
|
||||
# know, so the recorded locations answer for it: an agent who just
|
||||
# recorded what it built is not asked again.
|
||||
if unjudged:
|
||||
recorded = {
|
||||
(path, symbol)
|
||||
for _sid, path, symbol in await coverage_svc._recorded_locations(g.user.id, project_id)
|
||||
}
|
||||
unjudged = [u for u in unjudged if (u["path"], u["symbol"]) not in recorded
|
||||
and not (u["kind"] == "file" and (u["path"], "") in recorded)]
|
||||
outcome = shape_check_svc.outcome_for(phase, unjudged)
|
||||
await shape_check_svc.record_shape_check(
|
||||
g.user.id, outcome, written=len(written), unjudged=len(unjudged),
|
||||
project_id=project_id,
|
||||
)
|
||||
body["outcome"] = outcome
|
||||
body["unjudged"] = unjudged
|
||||
if outcome == "blocked":
|
||||
body["reason"] = shape_check_svc.block_reason(
|
||||
unjudged, project_id=project_id, repo=repo_key or repo,
|
||||
)
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
@plugin_bp.get("/claim-session")
|
||||
@login_required
|
||||
async def claim_session():
|
||||
|
||||
@@ -122,6 +122,24 @@ async def bindings_for_project(user_id: int, project_id: int) -> list[RepoBindin
|
||||
return list(rows.scalars().all())
|
||||
|
||||
|
||||
async def is_bound(project_id: int, raw_repo: str) -> str:
|
||||
"""The normalized key when ``raw_repo`` is bound to ``project_id`` by
|
||||
anyone, else "". By anyone, not by the caller: a collaborator judging a
|
||||
shared project's shapes holds no binding of their own, and the question
|
||||
is whether the repo belongs to the project, not who bound it."""
|
||||
key = normalize_repo_key(raw_repo or "")
|
||||
if not key or not project_id:
|
||||
return ""
|
||||
async with async_session() as session:
|
||||
found = await session.execute(
|
||||
select(RepoBinding.id).where(
|
||||
RepoBinding.project_id == project_id,
|
||||
RepoBinding.repo_key == key,
|
||||
).limit(1)
|
||||
)
|
||||
return key if found.first() is not None else ""
|
||||
|
||||
|
||||
async def keys_for_project(user_id: int, project_id: int) -> list[str]:
|
||||
"""Every repo key bound to a project — the snippet→forge join (#2691).
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""The end-of-turn shape check: what a turn wrote that nobody has judged, and what the agent is asked.
|
||||
|
||||
WHY THIS EXISTS (milestone 439)
|
||||
|
||||
The shape ledger used to be filled by machinery: extraction found the
|
||||
definitions, framework rules decided which were one-offs, and the write-path
|
||||
hook stamped "instance of #N" with nobody reading. The agent that WROTE the
|
||||
code — the one participant who knows what it is — was asked only in an audit,
|
||||
weeks later, if anyone ran one. A first instance of a reusable piece was never
|
||||
asked about at all, so it was never recorded, and the next session rebuilt it.
|
||||
|
||||
So the question moves to the moment the knowledge exists. The client's write
|
||||
hooks keep a ledger of the definitions each turn wrote; its Stop hook sends
|
||||
them here at the end of the turn. Two jobs live on this side, as with the
|
||||
report check (services/report_check.py):
|
||||
|
||||
- DECIDING what is unjudged, against the ledger and the project's recorded
|
||||
snippets — something only the server can see.
|
||||
- OWNING THE WORDS. A hook carries timing and transport (plugin/PACKAGING.md);
|
||||
the instruction comes from here, so every client asks the same question
|
||||
and the wording changes in one place.
|
||||
|
||||
And it RECORDS every outcome, so "how much did agents leave unjudged after
|
||||
being asked" is a number rather than an impression. app_logs, for the reason
|
||||
report_check gives.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.app_log import AppLog
|
||||
|
||||
# check — the turn's first stop: block if anything is unjudged.
|
||||
# after — the stop that follows a block: never block again, only record
|
||||
# whether the agent answered.
|
||||
PHASES = ("check", "after")
|
||||
OUTCOMES = ("passed", "blocked", "judged_after_block", "left_after_block")
|
||||
|
||||
# How many shapes the reason names before "+N more". Enough to judge a normal
|
||||
# turn in one call; a turn that wrote more than this generated something.
|
||||
_LISTED = 25
|
||||
|
||||
|
||||
def _evidence_note(evidence: dict) -> str:
|
||||
"""One parenthesis of what the machinery holds — offered, never asserted."""
|
||||
parts = []
|
||||
looks = evidence.get("looks_like") or {}
|
||||
if looks.get("snippet_id"):
|
||||
score = looks.get("score")
|
||||
at = f" {score:.2f}" if isinstance(score, (int, float)) else ""
|
||||
parts.append(f"looks like #{looks['snippet_id']}{at}")
|
||||
stamped = evidence.get("stamped") or {}
|
||||
if stamped.get("snippet_id"):
|
||||
parts.append(f"the hook guessed #{stamped['snippet_id']}")
|
||||
if evidence.get("copies"):
|
||||
parts.append("other copies of it exist")
|
||||
if evidence.get("diverges_from"):
|
||||
parts.append(f"#{evidence['diverges_from']} is canon in this directory")
|
||||
return f" ({'; '.join(parts)})" if parts else ""
|
||||
|
||||
|
||||
def block_reason(unjudged: list[dict], *, project_id: int, repo: str) -> str:
|
||||
"""What the agent is told at the end of a turn that wrote unjudged shapes.
|
||||
|
||||
Phrased as the practice wanted (note #3565): what to decide and the one
|
||||
call that records it, not a prohibition. The reusing-code skill owns the
|
||||
longer version of what each verdict means.
|
||||
"""
|
||||
lines = []
|
||||
for item in unjudged[:_LISTED]:
|
||||
new = " — new" if item.get("status") == "new" else ""
|
||||
lines.append(
|
||||
f"- {item['path']} · {item['symbol']} ({item['kind']}){new}"
|
||||
f"{_evidence_note(item.get('evidence') or {})}"
|
||||
)
|
||||
more = len(unjudged) - _LISTED
|
||||
if more > 0:
|
||||
lines.append(f"- … and {more} more (list_shapes(project_id={project_id}, path=…) shows them)")
|
||||
repo_arg = f', repo="{repo}"' if repo else ""
|
||||
return (
|
||||
f"This turn wrote {len(unjudged)} definition{'s' if len(unjudged) != 1 else ''} "
|
||||
"that nobody has judged yet. You built them, so you know what each one is — "
|
||||
"record it now, while that is still true, so the next session starts from your "
|
||||
"answer instead of rebuilding it:\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\n\nFor each: something another part of the code should reuse → record it "
|
||||
"with create_snippet (name, code, when to reach for it, location); built from a "
|
||||
"recorded snippet → `instance` of it; a deliberate departure from one → "
|
||||
"`variant` with the why; a genuine one-off → `exempt` with a reason "
|
||||
"(reason_code one-off-handler, test-helper, scoped-css, pure-helper … indexes it). "
|
||||
"One call records them all: "
|
||||
f"classify_shapes(project_id={project_id}{repo_arg}, classifications=[{{path, "
|
||||
"symbol, kind, status, snippet_id?, reason?, reason_code?}}, …]). "
|
||||
"`repo` lets a verdict land on a shape the ledger has not synced yet."
|
||||
)
|
||||
|
||||
|
||||
async def record_shape_check(
|
||||
user_id: int | None,
|
||||
outcome: str,
|
||||
*,
|
||||
written: int,
|
||||
unjudged: int,
|
||||
project_id: int | None = None,
|
||||
) -> None:
|
||||
if outcome not in OUTCOMES:
|
||||
raise ValueError(f"unknown shape-check outcome {outcome!r}")
|
||||
details: dict = {"outcome": outcome, "written": written, "unjudged": unjudged}
|
||||
if project_id:
|
||||
details["project_id"] = project_id
|
||||
async with async_session() as session:
|
||||
session.add(AppLog(
|
||||
category="plugin",
|
||||
user_id=user_id,
|
||||
action="shape_check",
|
||||
details=json.dumps(details),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
def outcome_for(phase: str, unjudged: list[dict]) -> str:
|
||||
"""The recorded outcome: a first stop blocks or passes; the stop after a
|
||||
block records whether the agent answered, and never blocks."""
|
||||
if phase == "after":
|
||||
return "left_after_block" if unjudged else "judged_after_block"
|
||||
return "blocked" if unjudged else "passed"
|
||||
|
||||
|
||||
def parse_written(raw: str, *, cap: int = 200) -> list[tuple[str, str, str]]:
|
||||
"""The hook's ledger lines, `path<TAB>kind<TAB>name`, as triples.
|
||||
|
||||
Kinds outside the ledger's vocabulary and malformed lines are dropped
|
||||
rather than echoed into an instruction; duplicates collapse; capped."""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
for line in (raw or "").splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
path, kind, name = (p.strip() for p in parts)
|
||||
if kind not in ("css", "sym", "file") or not path or not name:
|
||||
continue
|
||||
if (path, kind, name) not in out:
|
||||
out.append((path, kind, name))
|
||||
if len(out) >= cap:
|
||||
break
|
||||
return out
|
||||
@@ -535,6 +535,7 @@ async def classify_shapes(
|
||||
classifications: list[dict],
|
||||
*,
|
||||
via: str = "agent",
|
||||
repo: str = "",
|
||||
) -> dict:
|
||||
"""Apply a batch of judgments to a project's live ledger rows.
|
||||
|
||||
@@ -544,7 +545,19 @@ async def classify_shapes(
|
||||
item carries one — and a target no live row matches is reported in
|
||||
``unmatched``, not an error: the tree may simply have moved since the
|
||||
caller listed. Idempotent by construction.
|
||||
|
||||
``repo`` — a repo bound to the project — lets a judgment land on a shape
|
||||
the ledger has not synced yet (milestone 439): the shape the agent wrote
|
||||
this turn. Such an item becomes a PROVISIONAL row under that repo, the
|
||||
same row the write-path hook has always created, with no seen commit;
|
||||
the next sync confirms it, or marks it vanished until the code reaches
|
||||
the bound ref, and either way the judgment rides along (a vanished row
|
||||
that reappears keeps its status). Without ``repo`` an unknown shape is
|
||||
`unmatched`, exactly as before. An unbound ``repo`` is refused rather
|
||||
than ignored: a verdict silently dropped reads as one recorded.
|
||||
"""
|
||||
from scribe.services import repo_bindings as repo_bindings_svc
|
||||
|
||||
from scribe.services import access
|
||||
from scribe.services import snippets as snippets_svc
|
||||
|
||||
@@ -569,9 +582,18 @@ async def classify_shapes(
|
||||
for sid in sorted(target_ids):
|
||||
if await snippets_svc.get_snippet(user_id, sid) is None:
|
||||
raise ValueError(f"snippet {sid} not found (or not readable)")
|
||||
repo_key = ""
|
||||
if (repo or "").strip():
|
||||
repo_key = await repo_bindings_svc.is_bound(project_id, repo)
|
||||
if not repo_key:
|
||||
raise ValueError(
|
||||
f"repo {repo!r} is not bound to project {project_id} — "
|
||||
"bind_repo it, or omit repo to judge synced shapes only"
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
classified = 0
|
||||
provisional = 0
|
||||
unmatched: list[dict] = []
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
@@ -592,6 +614,17 @@ async def classify_shapes(
|
||||
kind = (item.get("kind") or "").strip()
|
||||
if kind:
|
||||
matches = [r for r in matches if r.kind == kind]
|
||||
if not matches and repo_key and item.get("status") != "unclassified":
|
||||
path = (item.get("path") or "").strip()
|
||||
symbol = (item.get("symbol") or "").strip()
|
||||
row = CodeShape(
|
||||
project_id=project_id, repo_key=repo_key, path=path,
|
||||
symbol=symbol, kind=kind or snippet_kind(symbol, ""),
|
||||
)
|
||||
session.add(row)
|
||||
by_key.setdefault((path, symbol), []).append(row)
|
||||
matches = [row]
|
||||
provisional += 1
|
||||
if not matches:
|
||||
unmatched.append({
|
||||
"path": item.get("path"), "symbol": item.get("symbol"),
|
||||
@@ -610,7 +643,10 @@ async def classify_shapes(
|
||||
evidence=item.get("reason"))
|
||||
classified += 1
|
||||
await session.commit()
|
||||
return {"classified": classified, "unmatched": unmatched}
|
||||
out = {"classified": classified, "unmatched": unmatched}
|
||||
if provisional:
|
||||
out["provisional"] = provisional
|
||||
return out
|
||||
|
||||
|
||||
def rule_matches(row: CodeShape, *, path: str, pattern: str, kind: str) -> bool:
|
||||
@@ -2550,6 +2586,83 @@ async def flag_divergence(project_id: int, *, since: datetime | None) -> int:
|
||||
return flagged
|
||||
|
||||
|
||||
# --- the end-of-turn question (milestone 439): what did this turn write that
|
||||
# nobody has judged? ---------------------------------------------------------
|
||||
#
|
||||
# The write hooks keep a session ledger of the definitions each turn wrote; at
|
||||
# the end of the turn the client asks this, and the agent that wrote them
|
||||
# answers. "Judged" means a verdict SOMEONE READ: an agent's, an audit's, an
|
||||
# import's. The sync's `scoped` stamp, `unclassified`, a hook stamp and no row
|
||||
# at all are all unjudged — each is a machine's guess or nothing, and the
|
||||
# point of the question is that the writer, who knows, says.
|
||||
|
||||
def _is_judged(row) -> bool:
|
||||
return (
|
||||
row.status not in _MECHANICAL_TODO
|
||||
and row.classified_by not in _UNATTENDED_BY
|
||||
)
|
||||
|
||||
|
||||
async def unjudged_shapes(
|
||||
project_id: int, written: list[tuple[str, str, str]], *, repo_key: str = "",
|
||||
) -> list[dict]:
|
||||
"""The (path, kind, name) shapes in ``written`` that carry no judgment.
|
||||
|
||||
Each comes back as {path, kind, symbol, status, evidence} — status is the
|
||||
row's, or "new" when the ledger has none yet; evidence is what the
|
||||
machinery holds that the writer may want to weigh: the proposer's
|
||||
candidate canon, a hook's stamp, a divergence flag. Evidence, never a
|
||||
verdict — the writer is asked because the machine's guesses were the
|
||||
thing that poisoned two ledgers. Order follows ``written``; duplicates
|
||||
collapse. The caller has already resolved the project and checked access.
|
||||
"""
|
||||
wanted: list[tuple[str, str, str]] = []
|
||||
for path, kind, name in written:
|
||||
key = ((path or "").strip(), (kind or "").strip(), (name or "").strip())
|
||||
if all(key) and key not in wanted:
|
||||
wanted.append(key)
|
||||
if not project_id or not wanted:
|
||||
return []
|
||||
paths = sorted({p for p, _k, _n in wanted})
|
||||
query = select(CodeShape).where(
|
||||
CodeShape.project_id == project_id,
|
||||
CodeShape.vanished_at.is_(None),
|
||||
CodeShape.path.in_(paths),
|
||||
)
|
||||
if repo_key:
|
||||
query = query.where(CodeShape.repo_key == repo_key)
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(query)).scalars().all()
|
||||
by_key = {(r.path, r.kind, r.symbol): r for r in rows}
|
||||
out: list[dict] = []
|
||||
for path, kind, name in wanted:
|
||||
row = by_key.get((path, kind, name))
|
||||
if row is not None and _is_judged(row):
|
||||
continue
|
||||
evidence: dict = {}
|
||||
if row is not None:
|
||||
if row.proposed_snippet_id:
|
||||
evidence["looks_like"] = {
|
||||
"snippet_id": row.proposed_snippet_id,
|
||||
"basis": row.proposal_basis,
|
||||
"score": row.proposal_score,
|
||||
}
|
||||
if row.classified_by in _UNATTENDED_BY and row.snippet_id:
|
||||
evidence["stamped"] = {
|
||||
"snippet_id": row.snippet_id, "score": stamp_score(row.reason),
|
||||
}
|
||||
if row.proposal_group:
|
||||
evidence["copies"] = row.proposal_group
|
||||
if row.diverges_from:
|
||||
evidence["diverges_from"] = row.diverges_from
|
||||
out.append({
|
||||
"path": path, "kind": kind, "symbol": name,
|
||||
"status": row.status if row is not None else "new",
|
||||
"evidence": evidence,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# How many of a canon's rows a review listing shows before "…" — enough to
|
||||
# judge from, not the whole table.
|
||||
_REVIEW_ROWS_SHOWN = 12
|
||||
|
||||
Reference in New Issue
Block a user