feat(prisma): eligible --max-chars, deterministic unavailable-fulltext path, qwen silence fix (refs P43)

This commit is contained in:
kert
2026-08-20 10:21:06 -04:00
parent e5c00bd66b
commit 1d1bdee84f
2 changed files with 54 additions and 6 deletions

View File

@@ -168,6 +168,12 @@ def screen(
def eligible(
name: str = typer.Argument(..., help="Project slug."),
limit: int = typer.Option(0, "--limit", "-n"),
max_chars: int = typer.Option(
0,
"--max-chars",
help="Cap per-item markdown (full text) at N chars so bounded-"
"context local models keep the criteria in window. 0 = no cap.",
),
db: Path = typer.Option(_DEFAULT_ZOT, "--db"),
storage: Path = typer.Option(_DEFAULT_STORAGE, "--storage"),
hold: bool = typer.Option(True, "--hold/--no-hold"),
@@ -188,6 +194,7 @@ def eligible(
project,
storage_dir=storage,
limit=limit or None,
max_fulltext_chars=max_chars or None,
)
stats = _hold(_go, hold=hold)

View File

@@ -28,10 +28,17 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
_SYSTEM = """\
You are a systematic-review assistant running PRISMA stage 3
(full-text eligibility). The paper's full text is attached; apply the
criteria to the concrete methods, comparator, and outcomes reported.
# One-sentence system message: longer system prompts make qwen models
# served by Ollama return an EMPTY response (no text, no tool call,
# finish=stop) — same quirk screen.py hit; the working guidance lives in
# the cached first user message instead.
_SYSTEM = "You are a systematic-review assistant running PRISMA stage 3 (full-text eligibility)."
_GUIDANCE = """\
## Task
The paper's full text follows in the next message. Apply the criteria
to the concrete methods, comparator, and outcomes reported.
Call `record_eligibility_decision` exactly once. Cite the page or
section you relied on in `rationale`. If the full text is cut off
@@ -47,7 +54,8 @@ def _build_call(project: Project, item_markdown: str) -> LLMCall:
LLMMessage(
role="user",
content=(
"## Inclusion / exclusion criteria\n\n"
_GUIDANCE
+ "\n## Inclusion / exclusion criteria\n\n"
+ project.criteria
+ "\n\n## Exclusion reason codebook\n\n"
+ project.reasons
@@ -92,6 +100,9 @@ def _queue(db: Db, project: str, limit: int | None) -> list[int]:
return [r[0] for r in rows]
_TRUNCATION_NOTE = "\n\n[... full text truncated to fit the model context ...]\n"
def run(
db: Db,
provider: Provider,
@@ -100,8 +111,16 @@ def run(
storage_dir: Path,
limit: int | None = None,
progress: bool = True,
max_fulltext_chars: int | None = None,
) -> dict[str, int]:
"""Run eligibility screening over the queue."""
"""Run eligibility screening over the queue.
``max_fulltext_chars`` caps the per-item markdown so local models
with bounded context windows see the criteria and the front of the
paper rather than silently dropping the oldest tokens (Ollama
truncates from the head, which would discard the cached criteria
message before the paper's methods section).
"""
ids = _queue(db, project.name, limit)
stats = {"assessed": 0, "include": 0, "exclude": 0, "uncertain": 0, "errors": 0}
@@ -109,6 +128,28 @@ def run(
try:
snap = load_item(db, zot_id, storage_dir)
md = to_markdown(snap, include_fulltext=True)
if "\n# Full text\n" not in md:
# No retrievable full text — a deterministic PRISMA
# outcome, not an LLM question. (Small no-fulltext
# prompts also trip the qwen/Ollama empty-response
# quirk, so this path must not touch the model.)
apply_eligibility_decision(
db,
zot_id,
project.name,
{
"decision": "uncertain",
"reasons": ["unavailable-fulltext"],
"rationale": "No full text retrieved by the fetch "
"cascade; cannot assess eligibility.",
},
)
db.commit()
stats["uncertain"] += 1
stats["assessed"] += 1
continue
if max_fulltext_chars and len(md) > max_fulltext_chars:
md = md[:max_fulltext_chars] + _TRUNCATION_NOTE
call = _build_call(project, md)
result = provider.complete(call)
if not result.tool_calls: