From 2a593078682948765b08ccde65aa0bfed6a8b953 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:11:56 +0900 Subject: [PATCH 01/25] docs: design institutional research workflow --- Spec.md | 1 + ...-research-institutional-workflow-design.md | 571 ++++++++++++++++++ 2 files changed, 572 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-06-longtable-research-institutional-workflow-design.md diff --git a/Spec.md b/Spec.md index bbf261b..fd7a2ff 100644 --- a/Spec.md +++ b/Spec.md @@ -10,6 +10,7 @@ Repository framing and migration boundary are defined in `README.md`, `RELATIONS Active supporting docs are: +- `docs/superpowers/specs/2026-08-06-longtable-research-institutional-workflow-design.md` - `docs/ARCHITECTURE.md` - `docs/ACTIVE-DECISIONS.md` - `docs/CHECKPOINTING.md` diff --git a/docs/superpowers/specs/2026-08-06-longtable-research-institutional-workflow-design.md b/docs/superpowers/specs/2026-08-06-longtable-research-institutional-workflow-design.md new file mode 100644 index 0000000..45adae2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-longtable-research-institutional-workflow-design.md @@ -0,0 +1,571 @@ +# LongTable Research Institutional Workflow Design + +Status: researcher-approved design + +Date: 2026-08-06 + +Product surface: `LongTable Research` + +Repository: `/Users/hosung/dev/LongTable` + +## 1. Executive Decision + +LongTable exposes one researcher-facing scholarly research surface. Internally it routes a capability tree for protocol design, scholarly discovery, institution-aware access, supervised browser execution, local corpus management, screening, systematic review, meta-analysis readiness, synthesis, and publication rendering. + +One surface does not mean one monolithic `SKILL.md`. LongTable core owns research state, Researcher Checkpoints, DecisionRecords, stage transitions, and audit semantics. Database adapters, institution profiles, parsers, browser recipes, Research PDF Vault integration, and renderers remain isolated and testable internal modules. + +The default access model is: + +1. the researcher completes institution login and MFA; +2. LongTable uses the active authenticated browser to perform normal, permitted research actions; +3. LongTable never extracts or persists credentials, cookies, session tokens, or MFA material; +4. access ambiguity, session expiry, CAPTCHA, material protocol change, or failed invariants cause a real hard stop; +5. execution resumes only after a durable researcher decision. + +## 2. Goals + +- Let a researcher request one LongTable Research workflow from question formation through manuscript-ready outputs. +- Support APIs, publisher TDM routes, public scholarly services, institution link resolvers, and supervised computer use without bypassing access controls. +- Adapt to multiple institutions through reusable database adapters and declarative institution profiles. +- Preserve systematic review and meta-analysis reproducibility through pilot separation, protocol freeze, revision history, immutable manifests, and provenance. +- Minimize researcher monitoring through exception-based supervision and deterministic stage audits. +- Minimize model-token use by reserving LLM calls for interpretive research decisions. +- Store PDFs once in Research PDF Vault and reference them from research projects by stable identity and hash. +- Generate researcher reports, PRISMA artifacts, Methods, Results, supplements, reference files, and analysis-readiness packages from the same canonical artifacts. + +## 3. Non-Goals + +- Automating password entry, MFA completion, CAPTCHA solving, cookie export, session extraction, proxy/VPN bypass, or access-control circumvention. +- Treating metadata or abstracts as verified full-text evidence. +- Downloading every identified PDF before title/abstract screening. +- Allowing the model to change a frozen production query without a protocol amendment and researcher approval. +- Making a browser automation transcript, generated Word file, or provider-specific UI the source of truth. +- Requiring continuous researcher observation of normal pagination, export, parsing, download, or audit operations. +- Building a separate always-on autonomous research daemon in the first implementation. + +## 4. Existing Product Boundaries + +The design extends existing LongTable boundaries rather than introducing a parallel product: + +- `@longtable/core` owns provider-neutral research and state contracts. +- `@longtable/mcp` provides structured elicitation transport over durable QuestionRecords and DecisionRecords. +- `@longtable/scholar-research` provides scholarly search and evidence-recovery capabilities. +- `@longtable/research-search` remains a deprecated compatibility package. +- `packages/longtable` owns the unified researcher-facing command surface. +- `.longtable/` remains the canonical project state. +- Research PDF Vault is an external local-first canonical PDF service, not a replacement LongTable state store. + +The current `longtable-research` blanket prohibition on institution-login automation must be refined during implementation. The replacement boundary permits supervised actions inside a researcher-authenticated active browser while continuing to forbid credential automation, cookie reuse, session extraction, CAPTCHA bypass, proxy/VPN bypass, and unapproved access circumvention. + +## 5. Approved Researcher Journey + +```text +research request + -> project and Research Specification recovery + -> adaptive search-strategy proposal + -> MCP Researcher Checkpoint + -> small institution-aware pilot + -> pilot yield and quality report + -> MCP protocol-freeze checkpoint + -> production metadata search and export + -> export audit, normalization, and deduplication + -> title/abstract screening + -> potential-inclusion corpus + -> full-text acquisition plan checkpoint + -> lawful PDF acquisition and Research PDF Vault intake + -> full-text screening and adjudication + -> final-corpus freeze checkpoint + -> systematic review or meta-analysis-readiness workflow + -> synthesis + -> researcher report + -> manuscript and reproducibility package +``` + +Search results are not bulk-downloaded before screening. Records with absent or insufficient abstracts are sent to `full_text_needed_for_screening`; they are not silently excluded. + +## 6. Architecture + +```text +LongTable Research +| ++-- Research Protocol Controller +| +-- question, scope, eligibility, information sources +| +-- pilot and production separation +| +-- protocol revisions and freeze +| ++-- Checkpoint and Hook Engine +| +-- durable QuestionRecord and DecisionRecord +| +-- MCP elicitation first +| +-- blocking state transitions +| ++-- Discovery Capability +| +-- APIs and official TDM +| +-- metadata import/export +| +-- supervised computer use +| ++-- Adapter Registry +| +-- database adapters +| +-- institution profiles +| +-- browser recipes and page signatures +| ++-- Corpus Controller +| +-- parsing, normalization, and deduplication +| +-- acquisition and screening ledgers +| +-- Research PDF Vault integration +| ++-- Review and Analysis +| +-- PRISMA selection flow +| +-- systematic review +| +-- human validation and adjudication +| +-- meta-analysis readiness +| ++-- Reporting and Rendering + +-- researcher final report + +-- Methods, Results, tables, figures, supplements + +-- APA 7 and target-journal renderers +``` + +Internal capabilities are registry entries, not separate researcher-facing skills. The public skill and command surface remains LongTable Research; legacy scholarly-search commands remain compatibility aliases only. + +## 7. Core Domain Records + +### 7.1 ResearchRun + +- `runId` +- `projectId` +- `researchType` +- `stage` +- `status`: `active | blocked | completed | failed | cancelled` +- `protocolRevisionId` +- `institutionProfileId` +- `lastSuccessfulCursor` +- `openExceptions` +- `createdAt` +- `updatedAt` + +### 7.2 ProtocolRevision + +- exact research question and review type +- databases and their roles +- exact queries +- date, language, publication-type, and source filters +- inclusion and exclusion criteria +- retrieval and screening policies +- model-assisted screening policy +- human-validation policy +- analysis intent +- protocol hash +- amendment rationale and linked DecisionRecord + +### 7.3 StageReceipt + +- `runId` +- `stage` +- institution and database +- adapter and recipe versions +- protocol revision and hash +- expected and observed counts +- input and output artifact IDs +- SHA-256 values +- invariant results +- retry summary +- completion status and next stage + +### 7.4 InstitutionProfile + +- institution identifier and locale +- enabled databases +- access modes +- library link resolver configuration +- permitted and prohibited actions +- export formats and result caps +- storage destinations +- checkpoint policy +- profile approval DecisionRecord +- calibration and last-verified dates + +The profile never includes credentials, cookies, authentication headers, session tokens, or MFA material. + +### 7.5 ArtifactProvenance + +- stable artifact ID +- source stage and input IDs +- protocol revision +- generator/parser/renderer version +- local path or canonical paper ID +- SHA-256 +- access basis when applicable +- creation time + +## 8. Execution State Machine + +```text +SETUP + -> PILOT + -> PROTOCOL_CHECKPOINT + -> PRODUCTION_SEARCH + -> EXPORT_AUDIT + -> TITLE_ABSTRACT_SCREENING + -> FULLTEXT_PLAN_CHECKPOINT + -> FULLTEXT_ACQUISITION + -> FULLTEXT_SCREENING + -> CORPUS_FREEZE_CHECKPOINT + -> ANALYSIS + -> SYNTHESIS + -> RESEARCHER_REPORT + -> MANUSCRIPT_PACKAGE +``` + +Every completed transition writes a StageReceipt. A hard-stop event writes the latest safe cursor, changes the run to `blocked`, creates a QuestionRecord, attempts MCP elicitation, and rejects resume until a linked DecisionRecord authorizes the next action. + +### Immediate hard stops + +- `LOGIN_REQUIRED` +- `MFA_OR_CAPTCHA_REQUIRED` +- `SESSION_EXPIRED` +- `TERMS_OR_ACCESS_UNCLEAR` +- `QUERY_DRIFT_DETECTED` +- `DATABASE_RESULT_CAP_REACHED` +- `EXPORT_COUNT_MISMATCH` +- `DOWNLOAD_PATTERN_CHANGED` +- `UI_SIGNATURE_CHANGED` +- `SCREENING_RULE_AMBIGUOUS` +- `HUMAN_AI_SCREENING_CONFLICT` +- `FULLTEXT_MISSING_THRESHOLD_EXCEEDED` +- `ANALYSIS_METHOD_CHANGE` +- `CORPUS_FREEZE_REQUIRED` + +### Locally retryable events + +- transient network timeout +- delayed export generation +- incomplete download +- temporary page-render failure +- OneDrive/File Provider hydration delay + +Retryable events use a bounded retry policy and one summarized advisory record. Repeated identical failures are bundled into one checkpoint rather than interrupting the researcher for every record. + +## 9. MCP-First Researcher Checkpoints + +Required checkpoints use the existing `longtable-state` MCP `elicit_question` surface first. Advisory notices and ordinary progress remain nonblocking logs. If MCP elicitation is unavailable, declined, cancelled, or times out, the same pending QuestionRecord is rendered through the numbered fallback. There is no implicit default answer. + +### Search-strategy card + +LongTable presents one compact proposal containing: + +- review type and objective; +- recommended databases grouped as core, domain, complementary, normalization, and full-text resolution; +- time, language, publication-type, and source filters; +- recall/precision posture; +- known access limitations; +- actions: pilot, modify databases, modify scope, open detailed wizard, or cancel. + +If the researcher requests database modification, a second MCP multi-select question lists databases exposed by the current institution profile and available public adapters. The detailed wizard is progressive disclosure, not the default path. + +### Pilot-freeze card + +After pilot execution, LongTable reports per-database yield, estimated overlap, missing-abstract rate, relevance sample, result caps, query problems, and recommended amendments. The researcher freezes the production protocol or selects a material revision. Production cannot begin without the freeze decision. + +Other required MCP checkpoints cover access ambiguity, full-text acquisition planning, corpus freeze, screening conflicts, and analysis-method commitments. + +## 10. Pilot and Production Separation + +Pilot artifacts and production artifacts are separate. A pilot may explore UI behavior, access routes, synonyms, result caps, and small relevance samples. Pilot results cannot be silently merged into the production corpus. + +Production execution requires an approved ProtocolRevision. Its databases, queries, filters, time boundaries, languages, target types, and result handling are immutable for that revision. Any material change causes a hard stop and creates a new revision linked to a DecisionRecord. + +## 11. Capability, Database Adapter, and Institution Layers + +### 11.1 Provider-neutral research capabilities + +- `open` +- `verifyAuthenticated` +- `submitQuery` +- `applyFilters` +- `readResultCount` +- `exportMetadata` +- `verifyExport` +- `resolveFulltext` +- `downloadPermittedPdf` +- `suspend` +- `resume` + +Unsupported capabilities are explicit; adapters do not improvise an unapproved alternative. + +### 11.2 Database adapters + +Each adapter may include: + +- official API or TDM client; +- export parser; +- browser recipe; +- accessibility-first page signatures; +- result-cap and pagination rules; +- permitted full-text resolution paths; +- versioned fixtures and contract tests. + +API and official TDM paths take precedence. Supervised computer use is used for normal user actions that lack an adequate supported API. + +### 11.3 Institution profiles + +Institution profiles select available database adapters, access modes, library link resolvers, export defaults, institution-specific result caps, storage paths, and checkpoint rules. They do not duplicate database logic. + +### 11.4 New-institution calibration + +```text +researcher login + -> database and resolver discovery + -> small supervised dry run + -> draft profile and recipes + -> researcher MCP approval + -> local fixture and replay tests + -> small live smoke test + -> production eligibility +``` + +Generic computer-use exploration is allowed only in calibration or pilot. Production requires an approved profile and compatible recipe signature. + +## 12. Supervised Computer-Use Contract + +The researcher owns login and MFA. LongTable verifies that the intended database and authenticated page are present, loads the frozen protocol, performs the approved query and filters, reads the result count, exports metadata, verifies the file and parsed row count, writes a StageReceipt, and advances. + +Recipes prefer accessibility roles, labels, headings, and stable semantic landmarks. Coordinates may be a bounded fallback inside a verified page signature; they are not the primary selector. + +LongTable must not guess when a required control, result count, export dialog, full-text notice, access warning, or page signature differs from the approved recipe. It suspends and asks the researcher. + +## 13. Corpus and PDF Ownership + +PDFs are stored once in Research PDF Vault as canonical artifacts. Each research project stores a `pdf-manifest.jsonl` reference containing: + +- `paperId` +- SHA-256 +- canonical local path +- acquisition method and access basis +- version and source URL identifiers +- project inclusion and screening state + +Licensed PDFs are not copied into each project, committed to Git, embedded in LongTable logs, or redistributed in a reproducibility package. + +The default acquisition sequence is: + +```text +all metadata + -> normalize and deduplicate + -> title/abstract screening + -> potential-inclusion set + -> lawful full-text resolution + -> Research PDF Vault intake + -> full-text screening + -> final corpus +``` + +## 14. Local Project Contract + +```text +research-project/ ++-- protocol/ +| +-- research-question.yaml +| +-- search-protocol.yaml +| +-- inclusion-exclusion.yaml +| +-- database-profiles/ +| +-- amendments/ ++-- data/ +| +-- 00_raw-exports/ +| +-- 01_normalized/ +| +-- 02_deduplicated/ +| +-- 03_title-abstract-screening/ +| +-- 04_fulltext-screening/ +| +-- 05_analysis-ready/ ++-- corpus/ +| +-- papers.jsonl +| +-- acquisition-ledger.jsonl +| +-- pdf-manifest.jsonl +| +-- screening-decisions.jsonl +| +-- manual-action-queue.csv ++-- audit/ +| +-- stage-receipts.jsonl +| +-- checkpoints.jsonl +| +-- decisions.jsonl +| +-- exclusions.jsonl +| +-- deviations.jsonl +| +-- failures.jsonl ++-- reports/ ++-- manuscript/ ++-- analysis/ ++-- references/ ++-- .longtable/research-runs/ +``` + +Raw export files are immutable after intake. Derived layers point to upstream artifact IDs and hashes. + +## 15. Low-Audit and Low-Token Execution + +### Deterministic execution + +Pagination, export, file verification, CSV/RIS/XLS/NBIB parsing, DOI normalization, exact deduplication, checksums, PRISMA counting, tables, and flow rendering use deterministic code. They must not invoke an LLM. + +### Interpretive execution + +LLM use is reserved for query-concept expansion, relevance calibration, ambiguous screening, construct mapping, synthesis, and method reasoning. Material decisions still require the configured human-validation or Researcher Checkpoint policy. + +### Context minimization + +Each model call receives a stage-specific state slice: + +- protocol hash and relevant revision fields; +- current-stage summary; +- bounded record or evidence batch; +- open exceptions; +- required output schema. + +The full browser log, full run history, and unrelated PDFs are not injected. Results are cached by record/PDF hash, rule or codebook version, prompt version, and model version. Unchanged inputs are not reprocessed. + +### Exception-based supervision + +Normal execution records compact receipts without notifying the researcher. Recoverable errors use bounded local retry and advisory aggregation. Only hard-stop events and final reports request attention. + +## 16. Deterministic Audit Invariants + +At minimum: + +```text +identified = normalized + parse_rejected +normalized = unique + duplicate_links +title_abstract_screened = fulltext_candidates + excluded + pending +fulltext_sought = retrieved + not_retrieved +fulltext_assessed = final_included + fulltext_excluded + unresolved +``` + +Additional invariants require every rejection, exclusion, unresolved item, duplicate link, acquired PDF, and protocol deviation to have an explicit basis. PRISMA rendering is blocked if count invariants fail. + +## 17. Researcher Reporting and Publication Rendering + +Machine-readable protocol, corpus, audit, decision, and receipt artifacts are the source of truth. Researcher-facing and publication-facing files are renderings. + +### Researcher report + +- databases and protocol revisions executed; +- per-database yields and export verification; +- normalization, deduplication, screening, retrieval, and final inclusion counts; +- automatic retries and recovered failures; +- unresolved access and screening items; +- protocol deviations and linked decisions; +- analysis readiness; +- required researcher actions; +- generated manuscript and reproducibility artifacts. + +### Systematic-review package + +- information sources; +- exact search strategy; +- selection process; +- automation and human validation; +- full-text access and missing-text handling; +- study-selection Results; +- PRISMA flow and count table; +- database yield, included-study, and full-text exclusion tables; +- PRISMA 2020 and PRISMA-S materials; +- exact search strings and amendments. + +Planned runs render future-tense protocol language. Completed runs render past-tense methods and observed Results. No renderer may claim an unexecuted database, unavailable full text, or unapproved analysis. + +### Meta-analysis readiness package + +- study-level records; +- effect-size candidates and required statistics; +- construct mapping and evidence; +- sample and publication dependency clusters; +- missing-data and author-contact needs; +- extraction codebook; +- analysis-readiness report. + +Analysis-model selection remains a Researcher Checkpoint. + +### Reference and format renderers + +Canonical bibliographic records generate CSL JSON, RIS, BibTeX, APA 7 references, and unresolved-citation reports. Content is then rendered through APA 7, Korean journal, international journal, or user-supplied Word-template profiles. Paragraph and table provenance maps link rendered claims and counts to source artifacts and protocol revisions. + +## 18. Security and Rights Boundary + +- No credential, cookie, session-token, MFA, or authentication-header persistence. +- No CAPTCHA solving or access-control bypass. +- No production run on an unapproved institution profile or incompatible page signature. +- No licensed full-text redistribution. +- No model upload of restricted/Red-lane content unless a separate explicit policy and access decision permits it. +- Secrets stay in approved credential storage and never enter `.longtable`, manifests, transcripts, fixtures, or repository history. +- Access basis and terms reference are recorded without recording authentication material. + +## 19. Verification Strategy + +### Local automated tests + +1. provider-neutral capability contract tests; +2. export-parser golden tests using sanitized XLS, CSV, RIS, NBIB, and JSON fixtures; +3. browser-recipe replay against accessibility snapshots and local HTML fixtures; +4. hard-stop, MCP cancellation, fallback, decision, and resume tests; +5. count-invariant and property tests; +6. network, partial-download, corrupt-PDF, empty-export, count-mismatch, placeholder-file, interrupted-run, DOI-missing, session-expiry, and result-cap failure injection; +7. secret, licensed-content, and path-leak scans; +8. model-call budget tests; +9. complete synthetic review from request through Word package; +10. deterministic rerender and provenance checks. + +### Model-call budgets + +- pagination: zero model calls; +- export and parsing: zero; +- DOI deduplication: zero; +- PRISMA counting: zero; +- search-strategy proposal: at most one per unchanged protocol draft; +- pilot interpretation: at most one per completed pilot snapshot; +- screening: only configured batches and ambiguous items, with cache reuse. + +### Live institution smoke gate + +After local tests pass, the researcher logs in and approves a small smoke run: 5-20 records per database, one metadata export, one or two permitted PDFs, Research PDF Vault intake, and report rendering. Bulk production is prohibited until the institution profile and recipe receive explicit approval. + +## 20. Acceptance Criteria + +- Only one LongTable Research surface is exposed for the integrated workflow. +- Existing MCP elicitation is the preferred transport for required checkpoints. +- No hard-stop run can resume without a DecisionRecord. +- Approved protocol revisions are immutable during production. +- Repetitive mechanical stages consume zero model calls. +- Interrupted runs resume at the last safe cursor without duplicating artifacts. +- PRISMA and corpus count invariants pass before rendering. +- Every material record, exclusion, acquisition, and manuscript count has provenance. +- No credentials, browser sessions, secrets, or licensed PDFs enter LongTable state or Git. +- Research PDF Vault is the only canonical PDF store. +- Researcher final reports and manuscript-ready Word artifacts are generated from canonical machine-readable state. +- Rerendering the same approved state produces identical counts and traceable content. +- A new institution cannot enter production without calibration, tests, a live smoke run, and researcher approval. + +## 21. Researcher-Approved Decisions + +- `decision_msh9nbsk_fc52od`: researcher login followed by supervised automation. +- `decision_msh9pddd_zrc6lj`: pilot, checkpoint, then frozen production protocol. +- `decision_msh9rfum_kjz6v1`: screen metadata before potential-inclusion PDF acquisition and generate manuscript-ready outputs. +- `decision_msh9up1f_r65s1d`: Research PDF Vault is the single canonical PDF store. +- `decision_msh9wf2w_c8ovx7`: one surface with an internal capability tree. +- `decision_msh9zxqn_h63enn`: receipt-based state machine with real hard stops. +- `decision_msha2raa_0ewqpq`: adaptive single-card MCP search UX. +- `decision_msha6r03_qtd2gx`: required checkpoints use MCP first; advisory information is nonblocking. +- `decision_msha91su_rc5qiu`: capability, database, and institution adapter layers with calibration. +- `decision_mshaayqg_4jul4u`: deterministic, exception-supervised, low-token execution. +- `decision_mshahg8d_ryn4tx`: canonical artifacts with multi-profile researcher and manuscript rendering. +- `decision_mshancvj_tz6i1l`: fixture-first verification with a live institution smoke gate. + +## 22. Implementation Decomposition Boundary + +This design is intentionally broader than one safe implementation change. The implementation plan must decompose it into independently verifiable tranches while preserving the single product surface: + +1. core run, protocol, receipt, adapter, and checkpoint contracts; +2. MCP search-strategy and freeze UX; +3. deterministic corpus/export pipeline and local artifact contract; +4. Research PDF Vault integration; +5. browser executor, database recipes, and institution calibration; +6. screening and adjudication workflow; +7. reporting, PRISMA, reference, Word, and journal renderers; +8. fixture, failure, security, token-budget, E2E, and live-smoke gates. + +Each tranche requires its own implementation plan and evidence-based verification. No tranche may weaken the approved access, checkpoint, provenance, or token-budget contracts to simplify delivery. From 1b201c506a611f319b33dee08741970b361f8277 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:22:25 +0900 Subject: [PATCH 02/25] docs: plan institutional research workflow implementation --- ...ngtable-research-institutional-workflow.md | 625 ++++++++++++++++++ 1 file changed, 625 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-longtable-research-institutional-workflow.md diff --git a/docs/superpowers/plans/2026-08-06-longtable-research-institutional-workflow.md b/docs/superpowers/plans/2026-08-06-longtable-research-institutional-workflow.md new file mode 100644 index 0000000..7ef6acb --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-longtable-research-institutional-workflow.md @@ -0,0 +1,625 @@ +# LongTable Research Institutional Workflow Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Use `superpowers:test-driven-development` for every behavior change and `superpowers:verification-before-completion` before claiming completion. + +**Goal:** Implement the approved, reproducible institutional-database research workflow behind one LongTable Research surface, with durable hard stops, immutable production protocols, deterministic corpus processing, one canonical Research PDF Vault, and manuscript-ready outputs. + +**Architecture:** Extend `@longtable/scholar-research` into the provider-neutral execution core. Keep database and institution behavior behind typed adapters; route required decisions through existing LongTable QuestionRecord/DecisionRecord and MCP elicitation; expose the integrated workflow as `longtable research` while retaining `scholar-research` as a compatibility alias. Machine-readable protocol, corpus, audit, and provenance files are canonical; reports and Word files are deterministic renderings. + +**Tech Stack:** TypeScript 5.6, Node.js 18+, MCP SDK/Zod, existing LongTable core/checkpoint/CLI packages, deterministic CSV/RIS/NBIB/JSON parsers, SHA-256, JSONL/YAML/Markdown/HTML, and DOCX packaging through the bundled document runtime selected during Task 14. + +**Global Constraints:** Preserve user edits in `.longtable/state.json` and `CURRENT.md`. Never persist credentials, cookies, session tokens, MFA material, authentication headers, or licensed PDF bytes in project state or Git. No CAPTCHA/paywall/access-control bypass. Use deterministic code for pagination, parsing, deduplication, counts, PRISMA, and rendering. A production run requires an approved InstitutionProfile and frozen ProtocolRevision. Every hard stop must persist a QuestionRecord and reject resume until a linked DecisionRecord exists. Each task begins with a failing smoke/contract test, implements the smallest passing behavior, runs targeted verification, and creates an atomic commit. + +--- + +## Tranche 1 — Core run, protocol, receipt, adapter, and checkpoint contracts + +### Task 1: Define institutional-research domain contracts + +**Files:** +- Create: `packages/longtable-scholar-research/src/workflow-types.ts` +- Modify: `packages/longtable-scholar-research/src/index.ts` +- Test: `scripts/smoke-institutional-workflow.mjs` +- Modify: `package.json` + +**Step 1: Write the failing contract test** + +Assert that the package exports the exact stage sequence, hard-stop codes, retryable codes, run/protocol/profile constructors, and safe profile validator. The test must instantiate this minimal boundary: + +```ts +const revision = research.createProtocolRevision({ + id: "protocol-1", + revision: 1, + decisionRecordId: "decision-1", + databases: ["wos"], + queries: { wos: "TS=(hackathon AND competency)" }, + filters: { years: [2015, 2026], languages: ["en", "ko"], publicationTypes: ["article"] } +}); +assert(Object.isFrozen(revision)); +assertEqual(research.nextResearchStage("PILOT"), "PROTOCOL_CHECKPOINT"); +``` + +**Step 2: Run the test to verify it fails** + +Run: `npm run build --workspace @longtable/scholar-research && node scripts/smoke-institutional-workflow.mjs` +Expected: FAIL because `workflow-types.js` exports do not exist. + +**Step 3: Implement the contracts** + +Define and export: + +```ts +export const RESEARCH_STAGES = [ + "SETUP", "PILOT", "PROTOCOL_CHECKPOINT", "PRODUCTION_SEARCH", + "EXPORT_AUDIT", "TITLE_ABSTRACT_SCREENING", "FULLTEXT_PLAN_CHECKPOINT", + "FULLTEXT_ACQUISITION", "FULLTEXT_SCREENING", "CORPUS_FREEZE_CHECKPOINT", + "ANALYSIS", "SYNTHESIS", "RESEARCHER_REPORT", "MANUSCRIPT_PACKAGE" +] as const; + +export type ResearchRunStatus = "planned" | "running" | "blocked" | "completed" | "failed"; +export interface ProtocolRevision { readonly id: string; readonly revision: number; readonly frozenAt: string; readonly decisionRecordId: string; readonly protocolHash: string; readonly databases: readonly string[]; readonly queries: Readonly>; readonly filters: Readonly; } +export interface StageReceipt { readonly id: string; readonly runId: string; readonly stage: ResearchStage; readonly protocolRevisionId: string; readonly inputArtifactIds: readonly string[]; readonly outputArtifactIds: readonly string[]; readonly cursor: string; readonly createdAt: string; } +``` + +Also define ResearchRun, InstitutionProfile, ArtifactProvenance, capability names, database-adapter interfaces, hard-stop codes, retryable-event codes, canonical constructors, SHA-256 protocol hashing, deep freezing, and a validator that rejects forbidden secret-like profile keys. + +**Step 4: Register and run the smoke test** + +Add `smoke:institutional-workflow` before `typecheck` in root `npm test`. + +Run: `npm run smoke:institutional-workflow && npm run typecheck --workspace @longtable/scholar-research` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add package.json packages/longtable-scholar-research/src/index.ts packages/longtable-scholar-research/src/workflow-types.ts scripts/smoke-institutional-workflow.mjs +git commit -m "feat(research): define institutional workflow contracts" +``` + +### Task 2: Implement receipt-backed state transitions and real hard stops + +**Files:** +- Create: `packages/longtable-scholar-research/src/workflow-state.ts` +- Modify: `packages/longtable-scholar-research/src/index.ts` +- Test: `scripts/smoke-institutional-workflow.mjs` + +**Step 1: Add failing transition tests** + +Test valid advancement, invalid stage skipping, safe-cursor persistence, hard-stop creation, and resume rejection without an answer-linked decision. Include this assertion: + +```ts +const blocked = research.blockResearchRun(run, { + code: "EXPORT_COUNT_MISMATCH", + questionRecordId: "question-1", + safeCursor: "wos:page:3" +}); +assertThrows(() => research.resumeResearchRun(blocked, []), /DecisionRecord/); +``` + +**Step 2: Verify failure** + +Run: `npm run smoke:institutional-workflow` +Expected: FAIL because transition functions are absent. + +**Step 3: Implement state-machine guards** + +Implement `advanceResearchRun`, `blockResearchRun`, and `resumeResearchRun`. Advancement requires a StageReceipt for the current stage. Resume requires a DecisionRecord whose `id` matches the blocking QuestionRecord's `decisionRecordId` and whose checkpoint key matches the stop. Keep the last safe cursor stable across repeated identical failures. + +**Step 4: Verify** + +Run: `npm run smoke:institutional-workflow && npm run typecheck --workspace @longtable/scholar-research` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add packages/longtable-scholar-research/src/index.ts packages/longtable-scholar-research/src/workflow-state.ts scripts/smoke-institutional-workflow.mjs +git commit -m "feat(research): enforce receipt-backed hard stops" +``` + +### Task 3: Create the canonical local project scaffold and append-only stores + +**Files:** +- Create: `packages/longtable-scholar-research/src/project-store.ts` +- Modify: `packages/longtable-scholar-research/src/protocol.ts` +- Modify: `packages/longtable-scholar-research/src/index.ts` +- Test: `scripts/smoke-institutional-workflow.mjs` + +**Step 1: Add a failing filesystem test** + +Create a temporary project; assert the complete `protocol/`, `data/`, `corpus/`, `audit/`, `reports/`, `manuscript/`, `analysis/`, `references/`, and `.longtable/research-runs/` contract. Verify that append helpers write newline-delimited records and refuse mutation of an already frozen protocol revision. + +**Step 2: Verify failure** + +Run: `npm run smoke:institutional-workflow` +Expected: FAIL on missing directories/store functions. + +**Step 3: Implement deterministic stores** + +Implement `buildResearchProjectLayout`, `writeResearchProjectScaffold`, `appendJsonlRecord`, `readJsonlRecords`, `writeFrozenProtocolRevision`, and `readLatestProtocolRevision`. Write atomically through a same-directory temporary file plus rename for snapshots; use append-only JSONL for receipts, checkpoints, decisions, exclusions, deviations, and failures. + +**Step 4: Verify and commit** + +Run: `npm run smoke:institutional-workflow && npm run typecheck` + +```bash +git add packages/longtable-scholar-research/src/index.ts packages/longtable-scholar-research/src/project-store.ts packages/longtable-scholar-research/src/protocol.ts scripts/smoke-institutional-workflow.mjs +git commit -m "feat(research): scaffold canonical research projects" +``` + +--- + +## Tranche 2 — MCP-first search-strategy and freeze UX + +### Task 4: Build adaptive checkpoint cards and QuestionRecord mappings + +**Files:** +- Create: `packages/longtable-scholar-research/src/checkpoints.ts` +- Modify: `packages/longtable-scholar-research/src/index.ts` +- Modify: `packages/longtable-scholar-research/package.json` +- Test: `scripts/smoke-institutional-checkpoints.mjs` +- Modify: `package.json` + +**Step 1: Add failing checkpoint tests** + +Test one compact search-strategy card, progressive database multi-select only after `modify_databases`, pilot-freeze, full-text plan, corpus freeze, access ambiguity, screening conflict, and analysis-method questions. Required records must set `hardStop: true`, the correct `hardStopScope`, and `preferredSurfaces: ["mcp_elicitation", "numbered"]`. + +**Step 2: Verify failure** + +Run: `npm run build && node scripts/smoke-institutional-checkpoints.mjs` +Expected: FAIL because builders do not exist. + +**Step 3: Implement pure checkpoint builders** + +Add `@longtable/core` as a workspace dependency and depend on its types only. Generate stable checkpoint keys from run ID, protocol revision, stage, and stop code. Never choose a default response after cancel, decline, timeout, or unsupported MCP transport. + +**Step 4: Register, verify, and commit** + +Run: `npm run smoke:institutional-checkpoints && npm run typecheck` + +```bash +git add package.json package-lock.json packages/longtable-scholar-research/package.json packages/longtable-scholar-research/src/checkpoints.ts packages/longtable-scholar-research/src/index.ts scripts/smoke-institutional-checkpoints.mjs +git commit -m "feat(research): add MCP-first research checkpoint cards" +``` + +### Task 5: Expose one `longtable research` surface with compatibility aliases + +**Files:** +- Modify: `packages/longtable/src/cli.ts` +- Modify: `packages/longtable-mcp/src/server.ts` +- Modify: `packages/longtable/README.md` +- Modify: `docs/LONGTABLE-COMMAND-SURFACE.md` +- Test: `scripts/smoke-institutional-checkpoints.mjs` + +**Step 1: Add failing CLI/MCP routing tests** + +Assert `longtable research doctor|init|status|checkpoint` works, `longtable scholar-research ...` delegates to the same handlers with a deprecation field, and MCP `elicit_question` persists then reuses the same pending QuestionRecord on fallback. + +**Step 2: Verify failure** + +Run: `npm run build && node scripts/smoke-institutional-checkpoints.mjs` +Expected: FAIL on the `research` command. + +**Step 3: Implement routing** + +Rename the internal handler to `runLongTableResearch`; map both command names to it. Add `research` to command parsing/help. Do not expose database-specific commands as new top-level surfaces. Reuse existing MCP elicitation rather than creating a second question transport. + +**Step 4: Verify and commit** + +Run: `npm run smoke:institutional-checkpoints && npm run smoke:scholar-research && npm run typecheck` + +```bash +git add packages/longtable/src/cli.ts packages/longtable-mcp/src/server.ts packages/longtable/README.md docs/LONGTABLE-COMMAND-SURFACE.md scripts/smoke-institutional-checkpoints.mjs +git commit -m "feat(research): expose one integrated research surface" +``` + +--- + +## Tranche 3 — Deterministic corpus/export pipeline + +### Task 6: Implement sanitized export parsers and golden fixtures + +**Files:** +- Create: `packages/longtable-scholar-research/src/export-parsers.ts` +- Create: `packages/longtable-scholar-research/fixtures/exports/crossref.json` +- Create: `packages/longtable-scholar-research/fixtures/exports/pubmed.nbib` +- Create: `packages/longtable-scholar-research/fixtures/exports/scopus.ris` +- Create: `packages/longtable-scholar-research/fixtures/exports/wos.csv` +- Create: `packages/longtable-scholar-research/fixtures/exports/expected-records.json` +- Modify: `packages/longtable-scholar-research/src/index.ts` +- Test: `scripts/smoke-export-parsers.mjs` +- Modify: `package.json` + +**Step 1: Write failing golden tests** + +Assert identical canonical bibliographic records for DOI, title, authors, year, abstract, database source, source record ID, and export artifact hash. Include quoted CSV fields, folded RIS lines, multi-valued NBIB tags, missing DOI, Korean text, and empty export rejection. + +**Step 2: Verify failure** + +Run: `npm run build --workspace @longtable/scholar-research && node scripts/smoke-export-parsers.mjs` +Expected: FAIL on parser imports. + +**Step 3: Implement parsers without model calls** + +Implement `parseCsvExport`, `parseRisExport`, `parseNbibExport`, `parseJsonExport`, and `parseExportArtifact`. Return records plus parse rejections with explicit reasons; never discard malformed rows silently. + +**Step 4: Register, verify, and commit** + +Run: `npm run smoke:export-parsers && npm run typecheck --workspace @longtable/scholar-research` + +```bash +git add package.json packages/longtable-scholar-research/src packages/longtable-scholar-research/fixtures/exports scripts/smoke-export-parsers.mjs +git commit -m "feat(research): parse scholarly exports deterministically" +``` + +### Task 7: Normalize, deduplicate, and enforce corpus invariants + +**Files:** +- Create: `packages/longtable-scholar-research/src/corpus.ts` +- Create: `packages/longtable-scholar-research/src/invariants.ts` +- Modify: `packages/longtable-scholar-research/src/index.ts` +- Test: `scripts/smoke-corpus-pipeline.mjs` +- Modify: `package.json` + +**Step 1: Add failing deterministic and property-style tests** + +Test DOI normalization, exact DOI merge, exact normalized title/year fallback, version links, duplicate provenance, stable IDs, order-independent output, and all four approved count equations. Test that PRISMA eligibility is false when any equation fails. + +**Step 2: Verify failure** + +Run: `npm run build --workspace @longtable/scholar-research && node scripts/smoke-corpus-pipeline.mjs` +Expected: FAIL on corpus imports. + +**Step 3: Implement the pipeline** + +Implement pure `normalizeRecords`, `deduplicateRecords`, `calculateCorpusCounts`, and `verifyCorpusInvariants`. Keep every source link and rejection/exclusion basis. Sort canonical output by stable paper ID. + +**Step 4: Register, verify, and commit** + +Run: `npm run smoke:corpus-pipeline && npm run typecheck` + +```bash +git add package.json packages/longtable-scholar-research/src/corpus.ts packages/longtable-scholar-research/src/invariants.ts packages/longtable-scholar-research/src/index.ts scripts/smoke-corpus-pipeline.mjs +git commit -m "feat(research): build auditable canonical corpora" +``` + +--- + +## Tranche 4 — Research PDF Vault integration + +### Task 8: Intake lawful PDFs into one canonical vault + +**Files:** +- Create: `packages/longtable-scholar-research/src/pdf-vault.ts` +- Modify: `packages/longtable-scholar-research/src/index.ts` +- Test: `scripts/smoke-pdf-vault.mjs` +- Modify: `package.json` + +**Step 1: Add failing vault tests** + +Use synthetic PDF bytes. Assert SHA-256 identity, one canonical file per hash, project manifest references rather than copies, access-basis requirement, placeholder/partial/corrupt PDF rejection, and no licensed bytes beneath the project directory. + +**Step 2: Verify failure** + +Run: `npm run build --workspace @longtable/scholar-research && node scripts/smoke-pdf-vault.mjs` +Expected: FAIL on vault imports. + +**Step 3: Implement safe intake** + +Implement `inspectPdfCandidate`, `intakeResearchPdf`, `buildPdfManifestRecord`, and `verifyPdfManifest`. The caller supplies a configured absolute vault root; reject vault roots inside the project and canonical paths outside the root. Record paperId, SHA, source URL, acquisition method, access basis, version, inclusion state, and screening state—never auth material. + +**Step 4: Register, verify, and commit** + +Run: `npm run smoke:pdf-vault && npm run typecheck` + +```bash +git add package.json packages/longtable-scholar-research/src/pdf-vault.ts packages/longtable-scholar-research/src/index.ts scripts/smoke-pdf-vault.mjs +git commit -m "feat(research): integrate the canonical Research PDF Vault" +``` + +--- + +## Tranche 5 — Database adapters, supervised browser execution, and institution calibration + +### Task 9: Implement capability contracts and institution-profile approval + +**Files:** +- Create: `packages/longtable-scholar-research/src/adapters.ts` +- Create: `packages/longtable-scholar-research/src/institutions.ts` +- Create: `packages/longtable-scholar-research/fixtures/institutions/psu-sanitized.json` +- Modify: `packages/longtable-scholar-research/src/index.ts` +- Test: `scripts/smoke-institution-adapters.mjs` +- Modify: `package.json` + +**Step 1: Add failing contract tests** + +Run a fake adapter through open, authentication verification, query, filters, result count, export, export verification, full-text resolution, permitted download, suspend, and resume. Assert unsupported capabilities are explicit. Assert an institution remains ineligible until calibration receipt, fixture replay, live smoke receipt, and profile-approval DecisionRecord all exist. + +**Step 2: Verify failure** + +Run: `npm run build && node scripts/smoke-institution-adapters.mjs` +Expected: FAIL on adapter/profile imports. + +**Step 3: Implement typed layers** + +Add adapter registry, capability result union, sanitized InstitutionProfile loader/validator, and `assessProductionEligibility`. The PSU fixture contains database IDs, access modes, resolver patterns, export defaults, result caps, storage references, and verification dates only. + +**Step 4: Register, verify, and commit** + +Run: `npm run smoke:institution-adapters && npm run typecheck` + +```bash +git add package.json packages/longtable-scholar-research/src/adapters.ts packages/longtable-scholar-research/src/institutions.ts packages/longtable-scholar-research/src/index.ts packages/longtable-scholar-research/fixtures/institutions scripts/smoke-institution-adapters.mjs +git commit -m "feat(research): add database and institution adapter contracts" +``` + +### Task 10: Implement accessibility-first browser recipes and calibration replay + +**Files:** +- Create: `packages/longtable-scholar-research/src/browser-recipes.ts` +- Create: `packages/longtable-scholar-research/fixtures/browser/wos-results.html` +- Create: `packages/longtable-scholar-research/fixtures/browser/wos-export.html` +- Create: `packages/longtable-scholar-research/fixtures/browser/signatures.json` +- Modify: `packages/longtable-scholar-research/src/index.ts` +- Test: `scripts/smoke-browser-recipes.mjs` +- Modify: `package.json` + +**Step 1: Add failing replay tests** + +Assert semantic landmark matching, page-signature hashing, bounded coordinate fallback only after a matching signature, export-count extraction, and hard stops for login, MFA/CAPTCHA, session expiry, changed signatures, changed download patterns, terms ambiguity, and result caps. + +**Step 2: Verify failure** + +Run: `npm run build && node scripts/smoke-browser-recipes.mjs` +Expected: FAIL on recipe imports. + +**Step 3: Implement replayable recipes** + +Implement pure recipe validation/replay over sanitized accessibility snapshots. Add executor action/result types without embedding a browser vendor. Production execution accepts only approved recipe version + matching signature; generic exploration is flagged `pilot_or_calibration_only`. + +**Step 4: Register, verify, and commit** + +Run: `npm run smoke:browser-recipes && npm run typecheck` + +```bash +git add package.json packages/longtable-scholar-research/src/browser-recipes.ts packages/longtable-scholar-research/src/index.ts packages/longtable-scholar-research/fixtures/browser scripts/smoke-browser-recipes.mjs +git commit -m "feat(research): add supervised browser recipe replay" +``` + +--- + +## Tranche 6 — Screening and adjudication + +### Task 11: Implement title/abstract and full-text screening ledgers + +**Files:** +- Create: `packages/longtable-scholar-research/src/screening.ts` +- Modify: `packages/longtable-scholar-research/src/index.ts` +- Test: `scripts/smoke-screening.mjs` +- Modify: `package.json` + +**Step 1: Add failing screening tests** + +Test decisions `include`, `exclude`, `pending`, and `full_text_needed_for_screening`; rule/codebook versions; human and AI actor attribution; exclusion reasons; weak/missing abstract routing; batch cache keys; and deterministic candidate counts. + +**Step 2: Verify failure** + +Run: `npm run build && node scripts/smoke-screening.mjs` +Expected: FAIL on screening imports. + +**Step 3: Implement screening records and reducers** + +Implement stable screening decision IDs, append-only decision validation, latest-decision reduction, candidate selection, and full-text missing-threshold calculation. Do not call an LLM from these reducers. + +**Step 4: Register, verify, and commit** + +Run: `npm run smoke:screening && npm run typecheck` + +```bash +git add package.json packages/longtable-scholar-research/src/screening.ts packages/longtable-scholar-research/src/index.ts scripts/smoke-screening.mjs +git commit -m "feat(research): add reproducible screening ledgers" +``` + +### Task 12: Enforce human–AI conflict and method-change checkpoints + +**Files:** +- Modify: `packages/longtable-scholar-research/src/screening.ts` +- Modify: `packages/longtable-scholar-research/src/checkpoints.ts` +- Modify: `packages/longtable-scholar-research/src/workflow-state.ts` +- Test: `scripts/smoke-screening.mjs` + +**Step 1: Add failing conflict tests** + +Assert that human–AI disagreement, ambiguous rules, excess missing full text, analysis-method changes, and corpus freeze requirements create the correct hard stop and cannot advance or resume without the linked decision. + +**Step 2: Verify failure** + +Run: `npm run smoke:screening` +Expected: FAIL on conflict resolution behavior. + +**Step 3: Implement conflict aggregation** + +Bundle repeated conflicts by checkpoint key, retain all affected paper IDs in the audit record, and ask one MCP question. Implement explicit adjudication application that writes a new screening decision rather than mutating prior decisions. + +**Step 4: Verify and commit** + +Run: `npm run smoke:screening && npm run smoke:institutional-checkpoints && npm run typecheck` + +```bash +git add packages/longtable-scholar-research/src/screening.ts packages/longtable-scholar-research/src/checkpoints.ts packages/longtable-scholar-research/src/workflow-state.ts scripts/smoke-screening.mjs +git commit -m "feat(research): hard-stop ambiguous screening decisions" +``` + +--- + +## Tranche 7 — Reporting, PRISMA, references, Word, and journal renderers + +### Task 13: Render deterministic researcher and PRISMA reports + +**Files:** +- Create: `packages/longtable-scholar-research/src/reporting.ts` +- Create: `packages/longtable-scholar-research/src/prisma.ts` +- Modify: `packages/longtable-scholar-research/src/index.ts` +- Test: `scripts/smoke-research-reporting.mjs` +- Modify: `package.json` + +**Step 1: Add failing snapshot tests** + +Build a synthetic completed review and assert researcher report sections, exact database yields, recovered failures, unresolved actions, protocol deviations, analysis readiness, PRISMA count table, and SVG flow. Assert planned runs use future tense and completed runs use past tense. Assert rendering is blocked when invariants fail. + +**Step 2: Verify failure** + +Run: `npm run build && node scripts/smoke-research-reporting.mjs` +Expected: FAIL on renderer imports. + +**Step 3: Implement deterministic renderers** + +Render Markdown, JSON, CSV count table, and SVG from canonical artifacts only. Add ArtifactProvenance entries for every report/table/figure and stable sorting so reruns match byte-for-byte except where the caller explicitly changes the rendering timestamp. + +**Step 4: Register, verify, and commit** + +Run: `npm run smoke:research-reporting && npm run typecheck` + +```bash +git add package.json packages/longtable-scholar-research/src/reporting.ts packages/longtable-scholar-research/src/prisma.ts packages/longtable-scholar-research/src/index.ts scripts/smoke-research-reporting.mjs +git commit -m "feat(research): render auditable researcher and PRISMA reports" +``` + +### Task 14: Render references, manuscript sections, and APA 7 Word packages + +**Files:** +- Create: `packages/longtable-scholar-research/src/references.ts` +- Create: `packages/longtable-scholar-research/src/manuscript.ts` +- Create: `packages/longtable-scholar-research/src/word-renderer.ts` +- Create: `packages/longtable-scholar-research/src/render-profiles.ts` +- Create: `packages/longtable-scholar-research/fixtures/render-profiles/apa7.json` +- Create: `packages/longtable-scholar-research/fixtures/render-profiles/korean-journal.json` +- Modify: `packages/longtable-scholar-research/package.json` +- Modify: `packages/longtable-scholar-research/src/index.ts` +- Test: `scripts/smoke-manuscript-package.mjs` +- Modify: `package.json` + +**Step 1: Inspect and lock the local document runtime** + +Run the workspace dependency loader and `scripts/generate_longtable_bjet_apa7.py` inspection. Record the selected maintained DOCX library and exact package version in `packages/longtable-scholar-research/package.json`; do not invent OOXML when a supported local runtime exists. + +**Step 2: Add failing reference and Word tests** + +Assert deterministic CSL JSON, RIS, BibTeX, APA 7 references, unresolved-citation report, systematic-review Methods/Results, meta-analysis readiness tables, paragraph/table provenance map, valid DOCX ZIP structure, APA 7 memory/template profile, Korean-journal profile, and user-supplied template-profile validation. + +**Step 3: Verify failure** + +Run: `npm run build && node scripts/smoke-manuscript-package.mjs` +Expected: FAIL on renderer imports. + +**Step 4: Implement renderers** + +Generate content only from approved state. Refuse to claim an unexecuted database, unavailable full text, or unapproved analysis. Store hashes and source artifact IDs for every paragraph, table, and figure. Keep the source profile declarative so a target journal can be added without changing research state. + +**Step 5: Register, verify, and commit** + +Run: `npm run smoke:manuscript-package && npm run typecheck` + +```bash +git add package.json package-lock.json packages/longtable-scholar-research/package.json packages/longtable-scholar-research/src packages/longtable-scholar-research/fixtures/render-profiles scripts/smoke-manuscript-package.mjs +git commit -m "feat(research): render APA7 and journal manuscript packages" +``` + +--- + +## Tranche 8 — Failure, security, token-budget, E2E, and live-smoke gates + +### Task 15: Add failure injection, security scans, and model-call budgets + +**Files:** +- Create: `packages/longtable-scholar-research/src/retry.ts` +- Create: `packages/longtable-scholar-research/src/model-budget.ts` +- Create: `scripts/smoke-research-failures.mjs` +- Create: `scripts/audit-research-artifacts.mjs` +- Modify: `package.json` + +**Step 1: Add failing fault tests** + +Inject network timeout, delayed export, partial download, corrupt PDF, empty export, count mismatch, placeholder file, interrupted run, missing DOI, session expiry, result cap, and OneDrive hydration delay. Assert bounded retries for approved retryable events, one aggregated advisory, no retry for hard stops, idempotent resume, and zero model calls for mechanical stages. + +**Step 2: Add failing security tests** + +Seed synthetic credential/cookie/token/auth-header keys, licensed byte markers, and absolute path leaks. Assert audit failure. Assert clean fixtures pass and report exact artifact IDs without printing secret values. + +**Step 3: Implement retry and budget guards** + +Use deterministic capped exponential schedules returned as data rather than sleeping in tests. Implement stage budgets and cache keys from artifact hash + rule/codebook version + prompt version + model version. + +**Step 4: Register, verify, and commit** + +Run: `npm run smoke:research-failures && node scripts/audit-research-artifacts.mjs --fixtures && npm run typecheck` + +```bash +git add package.json packages/longtable-scholar-research/src/retry.ts packages/longtable-scholar-research/src/model-budget.ts scripts/smoke-research-failures.mjs scripts/audit-research-artifacts.mjs +git commit -m "test(research): enforce failure security and token budgets" +``` + +### Task 16: Complete the synthetic review and live institution smoke gate + +**Files:** +- Create: `scripts/e2e-institutional-review.mjs` +- Create: `docs/INSTITUTIONAL-RESEARCH-WORKFLOW.md` +- Modify: `README.md` +- Modify: `Spec.md` +- Modify: `package.json` +- Test: `scripts/e2e-institutional-review.mjs` + +**Step 1: Write the failing end-to-end scenario** + +Run a synthetic request through setup, pilot, protocol checkpoint, frozen production, export audit, title/abstract screening, full-text plan, PDF Vault intake, full-text screening, corpus freeze, analysis readiness, researcher report, PRISMA, references, and Word package. Simulate MCP cancellation followed by numbered fallback, process interruption/resume, and a protocol amendment. Assert no duplicate artifact and deterministic rerender. + +**Step 2: Verify failure** + +Run: `npm run build && node scripts/e2e-institutional-review.mjs` +Expected: FAIL until all layers are connected. + +**Step 3: Wire the integrated coordinator and CLI commands** + +Add `research pilot`, `research freeze`, `research ingest-export`, `research screen`, `research acquire`, `research report`, and `research package` subcommands behind the single surface. The live-smoke command permits 5–20 records, one metadata export, and one or two permitted PDFs only; it writes a receipt and requires researcher approval before setting production eligibility. + +**Step 4: Document the researcher workflow** + +Document login ownership, MCP checkpoints, numbered fallback, artifact locations, resume behavior, PDF Vault configuration, profile calibration, live-smoke limits, researcher final report, manuscript package, and the security boundary. Link the approved design and this implementation plan from `Spec.md`. + +**Step 5: Run full verification** + +Run: + +```bash +npm test +npm run pack:check +node scripts/audit-research-artifacts.mjs --repo . +git diff --check +git status --short +``` + +Expected: all tests pass; no secrets/licensed PDFs/path leaks; only the user's pre-existing `.longtable/state.json` and `CURRENT.md` modifications remain outside implementation commits. + +**Step 6: Commit** + +```bash +git add README.md Spec.md package.json docs/INSTITUTIONAL-RESEARCH-WORKFLOW.md packages/longtable packages/longtable-mcp packages/longtable-scholar-research scripts/e2e-institutional-review.mjs +git commit -m "feat(research): complete institutional review workflow" +``` + +--- + +## Completion Audit + +Before marking the implementation complete: + +1. Map every acceptance criterion in design section 20 to at least one passing automated assertion and one canonical artifact. +2. Confirm every hard-stop code has a creation, MCP/fallback, decision, and resume test. +3. Confirm every deterministic stage has a zero-model-call assertion. +4. Confirm every rendered count, paragraph, table, figure, and reference has ArtifactProvenance. +5. Confirm protocol revisions are frozen, raw exports are immutable after intake, and repeated resume/rerender operations are idempotent. +6. Confirm the Research PDF Vault is the only PDF byte store and project manifests contain references only. +7. Confirm a new institution cannot enter production without calibration, replay fixtures, live smoke, and profile approval. +8. Run `rg -n "TODO|TBD|FIXME|placeholder" packages/longtable-scholar-research packages/longtable-mcp packages/longtable scripts docs/INSTITUTIONAL-RESEARCH-WORKFLOW.md` and resolve any implementation placeholders introduced by this plan. +9. Review `git diff --stat`, `git diff --check`, and all commits while excluding the user's existing `.longtable/state.json` and `CURRENT.md` edits. From c0c830687a8b654a342a9ec69234623b1347308d Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:23:33 +0900 Subject: [PATCH 03/25] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 37c69a0..ff7dc2e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/ build/ .omx/ .longtable/team/ +.worktrees/ .codex/ .claude/ .env From 9453af92d0b8807b77596dc792d7abe946222e59 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:26:36 +0900 Subject: [PATCH 04/25] feat(research): define institutional workflow contracts --- package.json | 3 +- .../longtable-scholar-research/src/index.ts | 1 + .../src/workflow-types.ts | 254 ++++++++++++++++++ scripts/smoke-institutional-workflow.mjs | 131 +++++++++ 4 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 packages/longtable-scholar-research/src/workflow-types.ts create mode 100644 scripts/smoke-institutional-workflow.mjs diff --git a/package.json b/package.json index fdb27e6..aa3bd45 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "smoke:grilling-interview": "node scripts/e2e-grilling-interview-flow.mjs", "smoke:search": "node scripts/smoke-search.mjs", "smoke:scholar-research": "node scripts/smoke-scholar-research.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run typecheck", + "smoke:institutional-workflow": "node scripts/smoke-institutional-workflow.mjs", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index 2d0deed..c1f796f 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -5,3 +5,4 @@ export * from "./rank.js"; export * from "./run.js"; export * from "./publisher-access.js"; export * from "./protocol.js"; +export * from "./workflow-types.js"; diff --git a/packages/longtable-scholar-research/src/workflow-types.ts b/packages/longtable-scholar-research/src/workflow-types.ts new file mode 100644 index 0000000..037dbef --- /dev/null +++ b/packages/longtable-scholar-research/src/workflow-types.ts @@ -0,0 +1,254 @@ +import { createHash } from "node:crypto"; + +export const INSTITUTIONAL_RESEARCH_STAGES = [ + "SETUP", + "PILOT", + "PROTOCOL_CHECKPOINT", + "PRODUCTION_SEARCH", + "EXPORT_AUDIT", + "TITLE_ABSTRACT_SCREENING", + "FULLTEXT_PLAN_CHECKPOINT", + "FULLTEXT_ACQUISITION", + "FULLTEXT_SCREENING", + "CORPUS_FREEZE_CHECKPOINT", + "ANALYSIS", + "SYNTHESIS", + "RESEARCHER_REPORT", + "MANUSCRIPT_PACKAGE" +] as const; + +export type InstitutionalResearchStage = typeof INSTITUTIONAL_RESEARCH_STAGES[number]; + +export const INSTITUTIONAL_RESEARCH_HARD_STOPS = [ + "LOGIN_REQUIRED", + "MFA_OR_CAPTCHA_REQUIRED", + "SESSION_EXPIRED", + "TERMS_OR_ACCESS_UNCLEAR", + "QUERY_DRIFT_DETECTED", + "DATABASE_RESULT_CAP_REACHED", + "EXPORT_COUNT_MISMATCH", + "DOWNLOAD_PATTERN_CHANGED", + "UI_SIGNATURE_CHANGED", + "SCREENING_RULE_AMBIGUOUS", + "HUMAN_AI_SCREENING_CONFLICT", + "FULLTEXT_MISSING_THRESHOLD_EXCEEDED", + "ANALYSIS_METHOD_CHANGE", + "CORPUS_FREEZE_REQUIRED" +] as const; + +export type InstitutionalResearchHardStop = typeof INSTITUTIONAL_RESEARCH_HARD_STOPS[number]; + +export const INSTITUTIONAL_RESEARCH_RETRYABLE_EVENTS = [ + "TRANSIENT_NETWORK_TIMEOUT", + "DELAYED_EXPORT_GENERATION", + "INCOMPLETE_DOWNLOAD", + "TEMPORARY_PAGE_RENDER_FAILURE", + "FILE_PROVIDER_HYDRATION_DELAY" +] as const; + +export type InstitutionalResearchRetryableEvent = typeof INSTITUTIONAL_RESEARCH_RETRYABLE_EVENTS[number]; + +export const INSTITUTIONAL_RESEARCH_CAPABILITIES = [ + "open", + "verifyAuthenticated", + "submitQuery", + "applyFilters", + "readResultCount", + "exportMetadata", + "verifyExport", + "resolveFulltext", + "downloadPermittedPdf", + "suspend", + "resume" +] as const; + +export type InstitutionalResearchCapability = typeof INSTITUTIONAL_RESEARCH_CAPABILITIES[number]; +export type ResearchRunStatus = "planned" | "running" | "blocked" | "completed" | "failed"; + +export interface SearchFilters { + readonly years?: readonly [number, number]; + readonly languages: readonly string[]; + readonly publicationTypes: readonly string[]; + readonly sourceTypes?: readonly string[]; +} + +export interface ProtocolRevisionInput { + readonly id: string; + readonly revision: number; + readonly frozenAt: string; + readonly decisionRecordId: string; + readonly databases: readonly string[]; + readonly queries: Readonly>; + readonly filters: SearchFilters; +} + +export interface ProtocolRevision extends ProtocolRevisionInput { + readonly protocolHash: string; +} + +export interface ResearchRun { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly status: ResearchRunStatus; + readonly stage: InstitutionalResearchStage; + readonly protocolRevisionId?: string; + readonly institutionProfileId?: string; + readonly latestSafeCursor?: string; + readonly blockingQuestionRecordId?: string; + readonly blockingCode?: InstitutionalResearchHardStop; +} + +export interface StageReceipt { + readonly id: string; + readonly runId: string; + readonly stage: InstitutionalResearchStage; + readonly protocolRevisionId: string; + readonly inputArtifactIds: readonly string[]; + readonly outputArtifactIds: readonly string[]; + readonly cursor: string; + readonly createdAt: string; +} + +export type InstitutionAccessMode = "official_api" | "tdm" | "browser_sso" | "public_browser" | "manual_export"; + +export interface InstitutionDatabaseAccess { + readonly databaseId: string; + readonly accessMode: InstitutionAccessMode; + readonly exportFormats?: readonly string[]; + readonly resultCap?: number; + readonly recipeId?: string; +} + +export interface InstitutionStorageProfile { + readonly pdfVaultRoot: string; + readonly exportRoot?: string; +} + +export interface InstitutionProfileInput { + readonly id: string; + readonly institutionName: string; + readonly approvedDecisionRecordId: string; + readonly databases: readonly InstitutionDatabaseAccess[]; + readonly storage: InstitutionStorageProfile; + readonly checkpointPolicy: "mcp_first"; + readonly calibratedAt: string; + readonly lastVerifiedAt: string; + readonly resolverUrls?: readonly string[]; +} + +export interface InstitutionProfile extends InstitutionProfileInput {} + +export interface ArtifactProvenance { + readonly id: string; + readonly sourceStage: InstitutionalResearchStage; + readonly inputArtifactIds: readonly string[]; + readonly protocolRevisionId: string; + readonly generator: string; + readonly generatorVersion: string; + readonly localPath?: string; + readonly paperId?: string; + readonly sha256: string; + readonly accessBasis?: string; + readonly createdAt: string; +} + +export type CapabilityResult = + | { readonly status: "supported"; readonly value: T } + | { readonly status: "unsupported"; readonly reason: string } + | { readonly status: "hard_stop"; readonly code: InstitutionalResearchHardStop; readonly reason: string } + | { readonly status: "retryable"; readonly code: InstitutionalResearchRetryableEvent; readonly reason: string }; + +export interface DatabaseAdapter { + readonly id: string; + readonly version: string; + readonly capabilities: readonly InstitutionalResearchCapability[]; +} + +const FORBIDDEN_PROFILE_KEY = /(?:password|passphrase|credential|cookie|session[_-]?token|access[_-]?token|refresh[_-]?token|auth(?:entication|orization)?[_-]?header|mfa|one[_-]?time[_-]?(?:code|password)|captcha)/i; + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stableValue); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, stableValue(entry)]) + ); + } + return value; +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function deepFreeze(value: T): Readonly { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + for (const nested of Object.values(value as Record)) { + deepFreeze(nested); + } + Object.freeze(value); + } + return value; +} + +function assertNoAuthenticationMaterial(value: unknown, path = "profile"): void { + if (!value || typeof value !== "object") { + return; + } + for (const [key, nested] of Object.entries(value as Record)) { + const nestedPath = `${path}.${key}`; + if (FORBIDDEN_PROFILE_KEY.test(key)) { + throw new Error(`Institution profile contains forbidden authentication material at ${nestedPath}.`); + } + assertNoAuthenticationMaterial(nested, nestedPath); + } +} + +function protocolHash(input: ProtocolRevisionInput): string { + const canonicalProtocol = stableValue({ + databases: input.databases, + queries: input.queries, + filters: input.filters + }); + return createHash("sha256").update(JSON.stringify(canonicalProtocol)).digest("hex"); +} + +export function nextInstitutionalResearchStage( + stage: InstitutionalResearchStage +): InstitutionalResearchStage | undefined { + const index = INSTITUTIONAL_RESEARCH_STAGES.indexOf(stage); + return index >= 0 ? INSTITUTIONAL_RESEARCH_STAGES[index + 1] : undefined; +} + +export function createProtocolRevision(input: ProtocolRevisionInput): ProtocolRevision { + if (!input.id.trim() || !input.decisionRecordId.trim()) { + throw new Error("A protocol revision requires stable protocol and DecisionRecord IDs."); + } + if (!Number.isInteger(input.revision) || input.revision < 1) { + throw new Error("A protocol revision number must be a positive integer."); + } + if (input.databases.length === 0) { + throw new Error("A protocol revision requires at least one database."); + } + const copy = cloneJson(input); + return deepFreeze({ + ...copy, + protocolHash: protocolHash(copy) + }) as ProtocolRevision; +} + +export function createInstitutionProfile(input: InstitutionProfileInput): InstitutionProfile { + assertNoAuthenticationMaterial(input); + if (!input.id.trim() || !input.institutionName.trim() || !input.approvedDecisionRecordId.trim()) { + throw new Error("An institution profile requires stable institution and approval DecisionRecord IDs."); + } + if (!input.storage.pdfVaultRoot.trim()) { + throw new Error("An institution profile requires a Research PDF Vault root."); + } + return deepFreeze(cloneJson(input)) as InstitutionProfile; +} diff --git a/scripts/smoke-institutional-workflow.mjs b/scripts/smoke-institutional-workflow.mjs new file mode 100644 index 0000000..d5f09a7 --- /dev/null +++ b/scripts/smoke-institutional-workflow.mjs @@ -0,0 +1,131 @@ +import { strict as assert } from "node:assert"; +import { resolve } from "node:path"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const research = await import(resolve(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); + +const expectedStages = [ + "SETUP", + "PILOT", + "PROTOCOL_CHECKPOINT", + "PRODUCTION_SEARCH", + "EXPORT_AUDIT", + "TITLE_ABSTRACT_SCREENING", + "FULLTEXT_PLAN_CHECKPOINT", + "FULLTEXT_ACQUISITION", + "FULLTEXT_SCREENING", + "CORPUS_FREEZE_CHECKPOINT", + "ANALYSIS", + "SYNTHESIS", + "RESEARCHER_REPORT", + "MANUSCRIPT_PACKAGE" +]; + +const expectedHardStops = [ + "LOGIN_REQUIRED", + "MFA_OR_CAPTCHA_REQUIRED", + "SESSION_EXPIRED", + "TERMS_OR_ACCESS_UNCLEAR", + "QUERY_DRIFT_DETECTED", + "DATABASE_RESULT_CAP_REACHED", + "EXPORT_COUNT_MISMATCH", + "DOWNLOAD_PATTERN_CHANGED", + "UI_SIGNATURE_CHANGED", + "SCREENING_RULE_AMBIGUOUS", + "HUMAN_AI_SCREENING_CONFLICT", + "FULLTEXT_MISSING_THRESHOLD_EXCEEDED", + "ANALYSIS_METHOD_CHANGE", + "CORPUS_FREEZE_REQUIRED" +]; + +const expectedRetryableEvents = [ + "TRANSIENT_NETWORK_TIMEOUT", + "DELAYED_EXPORT_GENERATION", + "INCOMPLETE_DOWNLOAD", + "TEMPORARY_PAGE_RENDER_FAILURE", + "FILE_PROVIDER_HYDRATION_DELAY" +]; + +assert.deepEqual(research.INSTITUTIONAL_RESEARCH_STAGES, expectedStages); +assert.deepEqual(research.INSTITUTIONAL_RESEARCH_HARD_STOPS, expectedHardStops); +assert.deepEqual(research.INSTITUTIONAL_RESEARCH_RETRYABLE_EVENTS, expectedRetryableEvents); +assert.equal(research.nextInstitutionalResearchStage("PILOT"), "PROTOCOL_CHECKPOINT"); +assert.equal(research.nextInstitutionalResearchStage("MANUSCRIPT_PACKAGE"), undefined); + +const revision = research.createProtocolRevision({ + id: "protocol-1", + revision: 1, + frozenAt: "2026-08-06T00:00:00.000Z", + decisionRecordId: "decision-1", + databases: ["wos"], + queries: { wos: "TS=(hackathon AND competency)" }, + filters: { + years: [2015, 2026], + languages: ["en", "ko"], + publicationTypes: ["article"] + } +}); + +assert.equal(revision.id, "protocol-1"); +assert.equal(revision.protocolHash.length, 64); +assert(Object.isFrozen(revision)); +assert(Object.isFrozen(revision.databases)); +assert(Object.isFrozen(revision.queries)); +assert(Object.isFrozen(revision.filters)); +assert.throws(() => revision.databases.push("scopus"), TypeError); + +const sameRevision = research.createProtocolRevision({ + id: "protocol-1-copy", + revision: 1, + frozenAt: "2026-08-07T00:00:00.000Z", + decisionRecordId: "decision-2", + databases: ["wos"], + queries: { wos: "TS=(hackathon AND competency)" }, + filters: { + years: [2015, 2026], + languages: ["en", "ko"], + publicationTypes: ["article"] + } +}); +assert.equal(revision.protocolHash, sameRevision.protocolHash); + +const profile = research.createInstitutionProfile({ + id: "psu", + institutionName: "Pennsylvania State University", + approvedDecisionRecordId: "decision-profile", + databases: [{ databaseId: "wos", accessMode: "browser_sso" }], + storage: { pdfVaultRoot: "/research-pdf-vault" }, + checkpointPolicy: "mcp_first", + calibratedAt: "2026-08-06T00:00:00.000Z", + lastVerifiedAt: "2026-08-06T00:00:00.000Z" +}); +assert.equal(profile.id, "psu"); +assert(Object.isFrozen(profile)); + +assert.throws(() => research.createInstitutionProfile({ + id: "unsafe", + institutionName: "Unsafe University", + approvedDecisionRecordId: "decision-unsafe", + databases: [], + storage: { pdfVaultRoot: "/research-pdf-vault" }, + checkpointPolicy: "mcp_first", + calibratedAt: "2026-08-06T00:00:00.000Z", + lastVerifiedAt: "2026-08-06T00:00:00.000Z", + sessionToken: "must-not-persist" +}), /forbidden authentication material/i); + +assert.deepEqual(research.INSTITUTIONAL_RESEARCH_CAPABILITIES, [ + "open", + "verifyAuthenticated", + "submitQuery", + "applyFilters", + "readResultCount", + "exportMetadata", + "verifyExport", + "resolveFulltext", + "downloadPermittedPdf", + "suspend", + "resume" +]); + +console.log("institutional workflow contracts smoke passed"); From d70519f3ee5975c46aec8941ec9ef1806c523728 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:28:30 +0900 Subject: [PATCH 05/25] feat(research): enforce receipt-backed hard stops --- .../longtable-scholar-research/src/index.ts | 1 + .../src/workflow-state.ts | 172 ++++++++++++++++++ .../src/workflow-types.ts | 2 + scripts/smoke-institutional-workflow.mjs | 88 +++++++++ 4 files changed, 263 insertions(+) create mode 100644 packages/longtable-scholar-research/src/workflow-state.ts diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index c1f796f..fc59c4b 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -6,3 +6,4 @@ export * from "./run.js"; export * from "./publisher-access.js"; export * from "./protocol.js"; export * from "./workflow-types.js"; +export * from "./workflow-state.js"; diff --git a/packages/longtable-scholar-research/src/workflow-state.ts b/packages/longtable-scholar-research/src/workflow-state.ts new file mode 100644 index 0000000..74df4a5 --- /dev/null +++ b/packages/longtable-scholar-research/src/workflow-state.ts @@ -0,0 +1,172 @@ +import { + INSTITUTIONAL_RESEARCH_HARD_STOPS, + nextInstitutionalResearchStage, + type InstitutionalResearchHardStop, + type InstitutionalResearchStage, + type ResearchRun, + type StageReceipt +} from "./workflow-types.js"; + +export interface CreateResearchRunInput { + readonly id: string; + readonly createdAt?: string; + readonly stage?: InstitutionalResearchStage; + readonly protocolRevisionId?: string; + readonly institutionProfileId?: string; +} + +export interface CreateStageReceiptInput extends StageReceipt {} + +export interface BlockResearchRunInput { + readonly code: InstitutionalResearchHardStop; + readonly questionRecordId: string; + readonly checkpointKey: string; + readonly safeCursor: string; + readonly blockedAt?: string; +} + +export interface ResearchDecisionRecordReference { + readonly id: string; + readonly checkpointKey: string; + readonly questionRecordId: string; +} + +function now(): string { + return new Date().toISOString(); +} + +function immutable(value: T): T { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + for (const nested of Object.values(value as Record)) { + immutable(nested); + } + Object.freeze(value); + } + return value; +} + +function requireText(value: string | undefined, label: string): string { + if (!value?.trim()) { + throw new Error(`${label} is required.`); + } + return value.trim(); +} + +export function createResearchRun(input: CreateResearchRunInput): ResearchRun { + const createdAt = input.createdAt ?? now(); + return immutable({ + id: requireText(input.id, "Research run ID"), + createdAt, + updatedAt: createdAt, + status: "planned", + stage: input.stage ?? "SETUP", + ...(input.protocolRevisionId ? { protocolRevisionId: input.protocolRevisionId } : {}), + ...(input.institutionProfileId ? { institutionProfileId: input.institutionProfileId } : {}) + }); +} + +export function createStageReceipt(input: CreateStageReceiptInput): StageReceipt { + requireText(input.id, "StageReceipt ID"); + requireText(input.runId, "StageReceipt run ID"); + requireText(input.protocolRevisionId, "StageReceipt protocol revision ID"); + requireText(input.cursor, "StageReceipt cursor"); + return immutable({ + ...input, + inputArtifactIds: [...input.inputArtifactIds], + outputArtifactIds: [...input.outputArtifactIds] + }); +} + +function assertReceiptMatchesRun(run: ResearchRun, receipt: StageReceipt): void { + if (run.status === "blocked") { + throw new Error("A blocked research run requires a linked DecisionRecord before it can advance."); + } + if (run.status === "completed" || run.status === "failed") { + throw new Error(`A ${run.status} research run cannot advance.`); + } + if (receipt.runId !== run.id) { + throw new Error("StageReceipt belongs to a different research run."); + } + if (receipt.stage !== run.stage) { + throw new Error(`StageReceipt must complete the current stage ${run.stage}.`); + } + if (run.protocolRevisionId && receipt.protocolRevisionId !== run.protocolRevisionId) { + throw new Error("StageReceipt protocol revision does not match the research run."); + } +} + +export function advanceResearchRun( + run: ResearchRun, + receipt: StageReceipt, + advancedAt = now() +): ResearchRun { + assertReceiptMatchesRun(run, receipt); + const nextStage = nextInstitutionalResearchStage(run.stage); + return immutable({ + ...run, + updatedAt: advancedAt, + status: nextStage ? "running" : "completed", + stage: nextStage ?? run.stage, + latestSafeCursor: receipt.cursor + }); +} + +export function blockResearchRun(run: ResearchRun, input: BlockResearchRunInput): ResearchRun { + if (!INSTITUTIONAL_RESEARCH_HARD_STOPS.includes(input.code)) { + throw new Error(`Unknown institutional research hard stop: ${input.code}`); + } + requireText(input.questionRecordId, "Blocking QuestionRecord ID"); + requireText(input.checkpointKey, "Blocking checkpoint key"); + requireText(input.safeCursor, "Safe cursor"); + if (run.status === "completed" || run.status === "failed") { + throw new Error(`A ${run.status} research run cannot be blocked.`); + } + + const repeatedStop = run.status === "blocked" && + run.blockingCode === input.code && + run.blockingQuestionRecordId === input.questionRecordId && + run.blockingCheckpointKey === input.checkpointKey; + + return immutable({ + ...run, + updatedAt: input.blockedAt ?? now(), + status: "blocked", + blockingQuestionRecordId: input.questionRecordId, + blockingCode: input.code, + blockingCheckpointKey: input.checkpointKey, + latestSafeCursor: repeatedStop ? run.latestSafeCursor : input.safeCursor + }); +} + +export function resumeResearchRun( + run: ResearchRun, + decisions: readonly ResearchDecisionRecordReference[], + resumedAt = now() +): ResearchRun { + if (run.status !== "blocked") { + throw new Error("Only a blocked research run can resume."); + } + const questionRecordId = requireText(run.blockingQuestionRecordId, "Blocking QuestionRecord ID"); + const checkpointKey = requireText(run.blockingCheckpointKey, "Blocking checkpoint key"); + const decision = decisions.find((candidate) => + Boolean(candidate.id.trim()) && + candidate.questionRecordId === questionRecordId && + candidate.checkpointKey === checkpointKey + ); + if (!decision) { + throw new Error("A linked DecisionRecord is required before this research run can resume."); + } + + const { + blockingQuestionRecordId: _question, + blockingCode: _code, + blockingCheckpointKey: _checkpoint, + ...safeRun + } = run; + return immutable({ + ...safeRun, + updatedAt: resumedAt, + status: "running", + resumedByDecisionRecordId: decision.id + }); +} diff --git a/packages/longtable-scholar-research/src/workflow-types.ts b/packages/longtable-scholar-research/src/workflow-types.ts index 037dbef..2f55018 100644 --- a/packages/longtable-scholar-research/src/workflow-types.ts +++ b/packages/longtable-scholar-research/src/workflow-types.ts @@ -97,6 +97,8 @@ export interface ResearchRun { readonly latestSafeCursor?: string; readonly blockingQuestionRecordId?: string; readonly blockingCode?: InstitutionalResearchHardStop; + readonly blockingCheckpointKey?: string; + readonly resumedByDecisionRecordId?: string; } export interface StageReceipt { diff --git a/scripts/smoke-institutional-workflow.mjs b/scripts/smoke-institutional-workflow.mjs index d5f09a7..7008fa6 100644 --- a/scripts/smoke-institutional-workflow.mjs +++ b/scripts/smoke-institutional-workflow.mjs @@ -128,4 +128,92 @@ assert.deepEqual(research.INSTITUTIONAL_RESEARCH_CAPABILITIES, [ "resume" ]); +const run = research.createResearchRun({ + id: "run-1", + createdAt: "2026-08-06T01:00:00.000Z", + stage: "PILOT", + protocolRevisionId: revision.id, + institutionProfileId: profile.id +}); +assert.equal(run.status, "planned"); +assert(Object.isFrozen(run)); + +const pilotReceipt = research.createStageReceipt({ + id: "receipt-pilot", + runId: run.id, + stage: "PILOT", + protocolRevisionId: revision.id, + inputArtifactIds: ["pilot-input"], + outputArtifactIds: ["pilot-output"], + cursor: "wos:pilot:complete", + createdAt: "2026-08-06T01:05:00.000Z" +}); +const checkpointRun = research.advanceResearchRun(run, pilotReceipt, "2026-08-06T01:05:00.000Z"); +assert.equal(checkpointRun.stage, "PROTOCOL_CHECKPOINT"); +assert.equal(checkpointRun.status, "running"); +assert.equal(checkpointRun.latestSafeCursor, "wos:pilot:complete"); +assert(Object.isFrozen(checkpointRun)); + +assert.throws(() => research.advanceResearchRun(checkpointRun, pilotReceipt), /current stage/i); + +const blocked = research.blockResearchRun(checkpointRun, { + code: "QUERY_DRIFT_DETECTED", + questionRecordId: "question-1", + checkpointKey: "run-1:protocol-1:PROTOCOL_CHECKPOINT:QUERY_DRIFT_DETECTED", + safeCursor: "wos:pilot:complete", + blockedAt: "2026-08-06T01:06:00.000Z" +}); +assert.equal(blocked.status, "blocked"); +assert.equal(blocked.blockingQuestionRecordId, "question-1"); +assert.equal(blocked.blockingCode, "QUERY_DRIFT_DETECTED"); +assert.equal(blocked.latestSafeCursor, "wos:pilot:complete"); + +const repeatedBlock = research.blockResearchRun(blocked, { + code: "QUERY_DRIFT_DETECTED", + questionRecordId: "question-1", + checkpointKey: "run-1:protocol-1:PROTOCOL_CHECKPOINT:QUERY_DRIFT_DETECTED", + safeCursor: "wos:pilot:unexpected-later-cursor", + blockedAt: "2026-08-06T01:07:00.000Z" +}); +assert.equal(repeatedBlock.latestSafeCursor, "wos:pilot:complete"); + +assert.throws(() => research.resumeResearchRun(blocked, []), /DecisionRecord/i); +assert.throws(() => research.resumeResearchRun(blocked, [{ + id: "decision-unrelated", + checkpointKey: "another-checkpoint", + questionRecordId: "question-1" +}]), /DecisionRecord/i); + +const resumed = research.resumeResearchRun(blocked, [{ + id: "decision-resume", + checkpointKey: "run-1:protocol-1:PROTOCOL_CHECKPOINT:QUERY_DRIFT_DETECTED", + questionRecordId: "question-1" +}], "2026-08-06T01:08:00.000Z"); +assert.equal(resumed.status, "running"); +assert.equal(resumed.stage, "PROTOCOL_CHECKPOINT"); +assert.equal(resumed.blockingQuestionRecordId, undefined); +assert.equal(resumed.blockingCode, undefined); +assert.equal(resumed.latestSafeCursor, "wos:pilot:complete"); + +const terminalRun = research.createResearchRun({ + id: "run-terminal", + createdAt: "2026-08-06T02:00:00.000Z", + stage: "MANUSCRIPT_PACKAGE", + protocolRevisionId: revision.id, + institutionProfileId: profile.id +}); +const terminalReceipt = research.createStageReceipt({ + id: "receipt-terminal", + runId: terminalRun.id, + stage: "MANUSCRIPT_PACKAGE", + protocolRevisionId: revision.id, + inputArtifactIds: ["manuscript-source"], + outputArtifactIds: ["manuscript-docx"], + cursor: "package:complete", + createdAt: "2026-08-06T02:05:00.000Z" +}); +const completed = research.advanceResearchRun(terminalRun, terminalReceipt); +assert.equal(completed.status, "completed"); +assert.equal(completed.stage, "MANUSCRIPT_PACKAGE"); + console.log("institutional workflow contracts smoke passed"); From 9f0fbe05cf16bc0c7f51d3d99ee47373c9352a2c Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:30:48 +0900 Subject: [PATCH 06/25] feat(research): scaffold canonical research projects --- .../longtable-scholar-research/src/index.ts | 1 + .../src/project-store.ts | 258 ++++++++++++++++++ scripts/smoke-institutional-workflow.mjs | 60 ++++ 3 files changed, 319 insertions(+) create mode 100644 packages/longtable-scholar-research/src/project-store.ts diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index fc59c4b..8aacbe1 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -7,3 +7,4 @@ export * from "./publisher-access.js"; export * from "./protocol.js"; export * from "./workflow-types.js"; export * from "./workflow-state.js"; +export * from "./project-store.js"; diff --git a/packages/longtable-scholar-research/src/project-store.ts b/packages/longtable-scholar-research/src/project-store.ts new file mode 100644 index 0000000..19729c6 --- /dev/null +++ b/packages/longtable-scholar-research/src/project-store.ts @@ -0,0 +1,258 @@ +import { randomUUID } from "node:crypto"; +import { + appendFile, + mkdir, + open, + readFile, + readdir, + rename, + writeFile +} from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { createProtocolRevision, type ProtocolRevision } from "./workflow-types.js"; + +export interface ResearchProjectLayout { + readonly root: string; + readonly protocol: { + readonly root: string; + readonly researchQuestion: string; + readonly searchProtocol: string; + readonly inclusionExclusion: string; + readonly databaseProfiles: string; + readonly amendments: string; + }; + readonly data: { + readonly root: string; + readonly rawExports: string; + readonly normalized: string; + readonly deduplicated: string; + readonly titleAbstractScreening: string; + readonly fulltextScreening: string; + readonly analysisReady: string; + }; + readonly corpus: { + readonly root: string; + readonly papers: string; + readonly acquisitionLedger: string; + readonly pdfManifest: string; + readonly screeningDecisions: string; + readonly manualActionQueue: string; + }; + readonly audit: { + readonly root: string; + readonly stageReceipts: string; + readonly checkpoints: string; + readonly decisions: string; + readonly exclusions: string; + readonly deviations: string; + readonly failures: string; + }; + readonly reports: string; + readonly manuscript: string; + readonly analysis: string; + readonly references: string; + readonly researchRuns: string; +} + +const JSONL_FILES = [ + "papers", + "acquisitionLedger", + "pdfManifest", + "screeningDecisions" +] as const; + +const AUDIT_JSONL_FILES = [ + "stageReceipts", + "checkpoints", + "decisions", + "exclusions", + "deviations", + "failures" +] as const; + +export function buildResearchProjectLayout(projectRoot: string): ResearchProjectLayout { + const root = resolve(projectRoot); + const protocolRoot = join(root, "protocol"); + const dataRoot = join(root, "data"); + const corpusRoot = join(root, "corpus"); + const auditRoot = join(root, "audit"); + return { + root, + protocol: { + root: protocolRoot, + researchQuestion: join(protocolRoot, "research-question.yaml"), + searchProtocol: join(protocolRoot, "search-protocol.yaml"), + inclusionExclusion: join(protocolRoot, "inclusion-exclusion.yaml"), + databaseProfiles: join(protocolRoot, "database-profiles"), + amendments: join(protocolRoot, "amendments") + }, + data: { + root: dataRoot, + rawExports: join(dataRoot, "00_raw-exports"), + normalized: join(dataRoot, "01_normalized"), + deduplicated: join(dataRoot, "02_deduplicated"), + titleAbstractScreening: join(dataRoot, "03_title-abstract-screening"), + fulltextScreening: join(dataRoot, "04_fulltext-screening"), + analysisReady: join(dataRoot, "05_analysis-ready") + }, + corpus: { + root: corpusRoot, + papers: join(corpusRoot, "papers.jsonl"), + acquisitionLedger: join(corpusRoot, "acquisition-ledger.jsonl"), + pdfManifest: join(corpusRoot, "pdf-manifest.jsonl"), + screeningDecisions: join(corpusRoot, "screening-decisions.jsonl"), + manualActionQueue: join(corpusRoot, "manual-action-queue.csv") + }, + audit: { + root: auditRoot, + stageReceipts: join(auditRoot, "stage-receipts.jsonl"), + checkpoints: join(auditRoot, "checkpoints.jsonl"), + decisions: join(auditRoot, "decisions.jsonl"), + exclusions: join(auditRoot, "exclusions.jsonl"), + deviations: join(auditRoot, "deviations.jsonl"), + failures: join(auditRoot, "failures.jsonl") + }, + reports: join(root, "reports"), + manuscript: join(root, "manuscript"), + analysis: join(root, "analysis"), + references: join(root, "references"), + researchRuns: join(root, ".longtable", "research-runs") + }; +} + +async function ensureFile(path: string, initialContent = ""): Promise { + await mkdir(dirname(path), { recursive: true }); + const handle = await open(path, "a"); + try { + const metadata = await handle.stat(); + if (metadata.size === 0 && initialContent) { + await handle.writeFile(initialContent, "utf8"); + } + } finally { + await handle.close(); + } +} + +export async function writeResearchProjectScaffold(projectRoot: string): Promise { + const layout = buildResearchProjectLayout(projectRoot); + const directories = [ + layout.protocol.root, + layout.protocol.databaseProfiles, + layout.protocol.amendments, + layout.data.rawExports, + layout.data.normalized, + layout.data.deduplicated, + layout.data.titleAbstractScreening, + layout.data.fulltextScreening, + layout.data.analysisReady, + layout.corpus.root, + layout.audit.root, + layout.reports, + layout.manuscript, + layout.analysis, + layout.references, + layout.researchRuns + ]; + await Promise.all(directories.map((directory) => mkdir(directory, { recursive: true }))); + await Promise.all([ + ensureFile(layout.protocol.researchQuestion, "{}\n"), + ensureFile(layout.protocol.searchProtocol, "{}\n"), + ensureFile(layout.protocol.inclusionExclusion, "{}\n"), + ...JSONL_FILES.map((key) => ensureFile(layout.corpus[key])), + ...AUDIT_JSONL_FILES.map((key) => ensureFile(layout.audit[key])), + ensureFile(layout.corpus.manualActionQueue, "paperId,action,reason,status\n") + ]); + return layout; +} + +export async function appendJsonlRecord(path: string, record: Readonly): Promise { + if (!record || typeof record !== "object" || Array.isArray(record)) { + throw new Error("A JSONL record must be an object."); + } + await mkdir(dirname(path), { recursive: true }); + await appendFile(path, `${JSON.stringify(record)}\n`, "utf8"); +} + +export async function readJsonlRecords = Record>( + path: string +): Promise { + let content: string; + try { + content = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } + return content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line, index) => { + try { + return JSON.parse(line) as T; + } catch (error) { + throw new Error(`Invalid JSONL at ${path}:${index + 1}: ${error instanceof Error ? error.message : String(error)}`); + } + }); +} + +async function atomicWrite(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporaryPath, content, { encoding: "utf8", flag: "wx" }); + await rename(temporaryPath, path); +} + +function revisionFilename(revision: number): string { + return `protocol-revision-${String(revision).padStart(4, "0")}.json`; +} + +async function existingRevision(path: string): Promise { + try { + const parsed = JSON.parse(await readFile(path, "utf8")) as ProtocolRevision; + return createProtocolRevision(parsed); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } +} + +export async function writeFrozenProtocolRevision( + layout: ResearchProjectLayout, + revision: ProtocolRevision +): Promise { + const path = join(layout.protocol.amendments, revisionFilename(revision.revision)); + const existing = await existingRevision(path); + if (existing) { + if (existing.protocolHash !== revision.protocolHash) { + throw new Error(`Protocol revision ${revision.revision} is immutable and already contains a different protocol hash.`); + } + return path; + } + + const serialized = `${JSON.stringify(revision, null, 2)}\n`; + await atomicWrite(path, serialized); + await atomicWrite(layout.protocol.searchProtocol, serialized); + return path; +} + +export async function readLatestProtocolRevision( + layout: ResearchProjectLayout +): Promise { + const entries = (await readdir(layout.protocol.amendments)) + .filter((entry) => /^protocol-revision-\d{4}\.json$/.test(entry)) + .sort(); + const latest = entries.at(-1); + if (!latest) { + throw new Error("No frozen ProtocolRevision exists in this research project."); + } + const revision = await existingRevision(join(layout.protocol.amendments, latest)); + if (!revision) { + throw new Error(`Frozen ProtocolRevision disappeared while reading ${latest}.`); + } + return revision; +} diff --git a/scripts/smoke-institutional-workflow.mjs b/scripts/smoke-institutional-workflow.mjs index 7008fa6..20c0243 100644 --- a/scripts/smoke-institutional-workflow.mjs +++ b/scripts/smoke-institutional-workflow.mjs @@ -1,4 +1,6 @@ import { strict as assert } from "node:assert"; +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { resolve } from "node:path"; const repoRoot = resolve(new URL("..", import.meta.url).pathname); @@ -216,4 +218,62 @@ const completed = research.advanceResearchRun(terminalRun, terminalReceipt); assert.equal(completed.status, "completed"); assert.equal(completed.stage, "MANUSCRIPT_PACKAGE"); +const projectRoot = await mkdtemp(resolve(tmpdir(), "longtable-institutional-project-")); +try { + const layout = research.buildResearchProjectLayout(projectRoot); + await research.writeResearchProjectScaffold(projectRoot); + + const expectedDirectories = [ + layout.protocol.root, + layout.protocol.databaseProfiles, + layout.protocol.amendments, + layout.data.rawExports, + layout.data.normalized, + layout.data.deduplicated, + layout.data.titleAbstractScreening, + layout.data.fulltextScreening, + layout.data.analysisReady, + layout.corpus.root, + layout.audit.root, + layout.reports, + layout.manuscript, + layout.analysis, + layout.references, + layout.researchRuns + ]; + for (const directory of expectedDirectories) { + assert.equal((await stat(directory)).isDirectory(), true, `missing scaffold directory: ${directory}`); + } + + await research.appendJsonlRecord(layout.audit.stageReceipts, pilotReceipt); + await research.appendJsonlRecord(layout.audit.stageReceipts, terminalReceipt); + const storedReceipts = await research.readJsonlRecords(layout.audit.stageReceipts); + assert.deepEqual(storedReceipts.map((entry) => entry.id), ["receipt-pilot", "receipt-terminal"]); + + const revisionPath = await research.writeFrozenProtocolRevision(layout, revision); + assert.equal((await stat(revisionPath)).isFile(), true); + assert.equal(await research.writeFrozenProtocolRevision(layout, revision), revisionPath); + assert.equal((await research.readLatestProtocolRevision(layout)).protocolHash, revision.protocolHash); + + const changedRevision = research.createProtocolRevision({ + id: "protocol-1-changed", + revision: 1, + frozenAt: "2026-08-08T00:00:00.000Z", + decisionRecordId: "decision-changed", + databases: ["wos"], + queries: { wos: "TS=(hackathon AND capability)" }, + filters: { + years: [2015, 2026], + languages: ["en", "ko"], + publicationTypes: ["article"] + } + }); + await assert.rejects( + research.writeFrozenProtocolRevision(layout, changedRevision), + /immutable/i + ); +} finally { + await rm(projectRoot, { recursive: true, force: true }); +} + console.log("institutional workflow contracts smoke passed"); From 72196624a8485a439fd946f001fc3d1fbf6a3db9 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:34:24 +0900 Subject: [PATCH 07/25] feat(research): add MCP-first research checkpoint cards --- package-lock.json | 3 + package.json | 3 +- .../longtable-scholar-research/package.json | 3 + .../src/checkpoints.ts | 376 ++++++++++++++++++ .../longtable-scholar-research/src/index.ts | 1 + scripts/smoke-institutional-checkpoints.mjs | 103 +++++ 6 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 packages/longtable-scholar-research/src/checkpoints.ts create mode 100644 scripts/smoke-institutional-checkpoints.mjs diff --git a/package-lock.json b/package-lock.json index 2918447..f82195d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1397,6 +1397,9 @@ "name": "@longtable/scholar-research", "version": "0.1.72", "license": "MIT", + "dependencies": { + "@longtable/core": "0.1.72" + }, "devDependencies": { "@types/node": "^22.10.1", "typescript": "^5.6.0" diff --git a/package.json b/package.json index aa3bd45..ace6a94 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "smoke:search": "node scripts/smoke-search.mjs", "smoke:scholar-research": "node scripts/smoke-scholar-research.mjs", "smoke:institutional-workflow": "node scripts/smoke-institutional-workflow.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run typecheck", + "smoke:institutional-checkpoints": "node scripts/smoke-institutional-checkpoints.mjs", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, diff --git a/packages/longtable-scholar-research/package.json b/packages/longtable-scholar-research/package.json index 0bd1182..053331f 100644 --- a/packages/longtable-scholar-research/package.json +++ b/packages/longtable-scholar-research/package.json @@ -20,6 +20,9 @@ "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" }, + "dependencies": { + "@longtable/core": "0.1.72" + }, "devDependencies": { "@types/node": "^22.10.1", "typescript": "^5.6.0" diff --git a/packages/longtable-scholar-research/src/checkpoints.ts b/packages/longtable-scholar-research/src/checkpoints.ts new file mode 100644 index 0000000..e279eb9 --- /dev/null +++ b/packages/longtable-scholar-research/src/checkpoints.ts @@ -0,0 +1,376 @@ +import { createHash } from "node:crypto"; +import type { + HardStopScope, + QuestionCommitmentFamily, + QuestionEpistemicBasis, + QuestionOption, + QuestionPromptType, + QuestionRecord +} from "@longtable/core"; + +interface CheckpointIdentity { + readonly runId: string; + readonly protocolRevisionId?: string; + readonly protocolReference?: string; + readonly createdAt?: string; +} + +export interface SearchStrategyCheckpointInput extends CheckpointIdentity { + readonly reviewType: string; + readonly objective: string; + readonly databaseGroups: { + readonly core: readonly string[]; + readonly domain: readonly string[]; + readonly complementary: readonly string[]; + readonly normalization: readonly string[]; + readonly fulltextResolution: readonly string[]; + }; + readonly filters: { + readonly years?: readonly [number, number]; + readonly languages: readonly string[]; + readonly publicationTypes: readonly string[]; + }; + readonly recallPrecisionPosture: "recall_first" | "balanced" | "precision_first"; + readonly accessLimitations: readonly string[]; +} + +export interface AvailableDatabase { + readonly id: string; + readonly label: string; + readonly group: "core" | "domain" | "complementary" | "normalization" | "fulltext_resolution"; + readonly available: boolean; + readonly limitation?: string; +} + +export interface DatabaseSelectionCheckpointInput extends CheckpointIdentity { + readonly availableDatabases: readonly AvailableDatabase[]; + readonly selectedDatabaseIds: readonly string[]; +} + +export interface PilotFreezeCheckpointInput extends CheckpointIdentity { + readonly databaseYields: Readonly>; + readonly estimatedOverlap: number; + readonly missingAbstractRate: number; + readonly relevanceSample: { readonly relevant: number; readonly reviewed: number }; + readonly resultCaps: readonly string[]; + readonly queryProblems: readonly string[]; + readonly recommendedAmendments: readonly string[]; +} + +export interface AccessAmbiguityCheckpointInput extends CheckpointIdentity { + readonly databaseId: string; + readonly issue: string; +} + +export interface FulltextPlanCheckpointInput extends CheckpointIdentity { + readonly candidateCount: number; + readonly lawfulRoutes: readonly string[]; + readonly unresolvedCount: number; +} + +export interface CorpusFreezeCheckpointInput extends CheckpointIdentity { + readonly includedCount: number; + readonly unresolvedCount: number; +} + +export interface ScreeningConflictCheckpointInput extends CheckpointIdentity { + readonly paperIds: readonly string[]; + readonly conflict: string; +} + +export interface AnalysisMethodCheckpointInput extends CheckpointIdentity { + readonly proposedMethod: string; + readonly rationale: string; +} + +interface RequiredCheckpointInput extends CheckpointIdentity { + readonly stage: string; + readonly code: string; + readonly title: string; + readonly question: string; + readonly type: QuestionPromptType; + readonly options: readonly QuestionOption[]; + readonly scope: HardStopScope; + readonly family: QuestionCommitmentFamily; + readonly epistemicBasis: QuestionEpistemicBasis; + readonly displayReason: string; + readonly rationale: readonly string[]; +} + +function requiredText(value: string | undefined, label: string): string { + if (!value?.trim()) { + throw new Error(`${label} is required for a research checkpoint.`); + } + return value.trim(); +} + +function checkpointReference(input: CheckpointIdentity): string { + return requiredText(input.protocolRevisionId ?? input.protocolReference, "Protocol reference"); +} + +export function buildInstitutionalCheckpointKey( + input: CheckpointIdentity & { readonly stage: string; readonly code: string } +): string { + return [ + "institutional", + requiredText(input.runId, "Run ID"), + checkpointReference(input), + requiredText(input.stage, "Research stage"), + requiredText(input.code, "Checkpoint code") + ].join(":"); +} + +function stableRecordId(prefix: string, checkpointKey: string): string { + return `${prefix}_${createHash("sha256").update(checkpointKey).digest("hex").slice(0, 20)}`; +} + +function buildRequiredCheckpoint(input: RequiredCheckpointInput): QuestionRecord { + const checkpointKey = buildInstitutionalCheckpointKey(input); + const createdAt = input.createdAt ?? new Date().toISOString(); + const recordId = stableRecordId("question_record", checkpointKey); + return { + id: recordId, + createdAt, + updatedAt: createdAt, + status: "pending", + hardStop: true, + hardStopScope: input.scope, + commitmentFamily: input.family, + epistemicBasis: input.epistemicBasis, + prompt: { + id: stableRecordId("question_prompt", checkpointKey), + checkpointKey, + title: input.title, + question: input.question, + type: input.type, + options: [...input.options], + allowOther: false, + required: true, + source: "checkpoint", + displayReason: input.displayReason, + rationale: [...input.rationale], + preferredSurfaces: ["mcp_elicitation", "numbered"] + }, + transportStatus: { + surface: "mcp_elicitation", + status: "not_attempted", + updatedAt: createdAt + } + }; +} + +function groupLine(label: string, values: readonly string[]): string { + return `${label}: ${values.length > 0 ? values.join(", ") : "none"}`; +} + +function listLine(label: string, values: readonly string[]): string { + return `${label}: ${values.length > 0 ? values.join(", ") : "none"}`; +} + +export function buildSearchStrategyCheckpoint(input: SearchStrategyCheckpointInput): QuestionRecord { + const yearText = input.filters.years ? `${input.filters.years[0]}–${input.filters.years[1]}` : "not limited"; + const question = [ + `Review type: ${input.reviewType}`, + `Objective: ${input.objective}`, + groupLine("Core databases", input.databaseGroups.core), + groupLine("Domain databases", input.databaseGroups.domain), + groupLine("Complementary databases", input.databaseGroups.complementary), + groupLine("Normalization", input.databaseGroups.normalization), + groupLine("Full-text resolution", input.databaseGroups.fulltextResolution), + `Time: ${yearText}`, + listLine("Languages", input.filters.languages), + listLine("Publication types", input.filters.publicationTypes), + `Recall/precision posture: ${input.recallPrecisionPosture}`, + listLine("Known access limitations", input.accessLimitations), + "How should LongTable proceed?" + ].join("\n"); + return buildRequiredCheckpoint({ + ...input, + stage: "SETUP", + code: "SEARCH_STRATEGY", + title: "Search strategy", + question, + type: "single_choice", + options: [ + { value: "pilot", label: "Run the pilot", description: "Test yield, overlap, missing abstracts, result caps, and relevance before freezing production.", recommended: true }, + { value: "modify_databases", label: "Modify databases", description: "Open a focused multi-select using databases available through the institution profile." }, + { value: "modify_scope", label: "Modify scope", description: "Revise time, language, publication type, or review objective." }, + { value: "open_detailed_wizard", label: "Open detailed wizard", description: "Inspect all strategy fields through progressive disclosure." }, + { value: "cancel", label: "Cancel", description: "Leave the run blocked without an implicit decision." } + ], + scope: "method", + family: "method", + epistemicBasis: "mixed", + displayReason: "The database and scope strategy determines the reproducible pilot frame.", + rationale: ["One compact card is the default researcher surface.", "Production is not authorized by this pilot decision."] + }); +} + +export function buildDatabaseSelectionCheckpoint(input: DatabaseSelectionCheckpointInput): QuestionRecord { + const unavailable = input.availableDatabases.filter((database) => !database.available); + const options = input.availableDatabases + .filter((database) => database.available) + .map((database): QuestionOption => ({ + value: database.id, + label: database.label, + description: `${database.group.replaceAll("_", " ")} database exposed by the current profile.`, + recommended: input.selectedDatabaseIds.includes(database.id) + })); + return buildRequiredCheckpoint({ + ...input, + stage: "SETUP", + code: "DATABASE_SELECTION", + title: "Database selection", + question: `Select the databases for the pilot. Unavailable: ${unavailable.map((database) => `${database.label} (${database.limitation ?? "unavailable"})`).join(", ") || "none"}.`, + type: "multi_choice", + options, + scope: "method", + family: "method", + epistemicBasis: "mixed", + displayReason: "The researcher requested database modification.", + rationale: ["This multi-select is progressive disclosure after modify_databases, not the default search screen."] + }); +} + +function percent(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +export function buildPilotFreezeCheckpoint(input: PilotFreezeCheckpointInput): QuestionRecord { + const yields = Object.entries(input.databaseYields) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([database, count]) => `${database}: ${count}`) + .join(", "); + return buildRequiredCheckpoint({ + ...input, + stage: "PROTOCOL_CHECKPOINT", + code: "PILOT_FREEZE", + title: "Pilot results and production freeze", + question: [ + `Database yields: ${yields || "none"}`, + `Estimated overlap: ${percent(input.estimatedOverlap)}`, + `Missing-abstract rate: ${percent(input.missingAbstractRate)}`, + `Relevance sample: ${input.relevanceSample.relevant}/${input.relevanceSample.reviewed}`, + listLine("Result caps", input.resultCaps), + listLine("Query problems", input.queryProblems), + listLine("Recommended amendments", input.recommendedAmendments), + "Freeze this revision for production or revise it?" + ].join("\n"), + type: "single_choice", + options: [ + { value: "freeze_production_protocol", label: "Freeze for production", description: "Make database, query, filter, language, time, and result handling immutable for this revision.", recommended: true }, + { value: "revise_protocol", label: "Revise protocol", description: "Create a material amendment and rerun the pilot checkpoint." }, + { value: "cancel", label: "Cancel", description: "Keep production blocked." } + ], + scope: "method", + family: "method", + epistemicBasis: "mixed", + displayReason: "Production cannot begin until the pilot evidence is reviewed and the protocol is frozen.", + rationale: ["Pilot artifacts remain separate from the production corpus."] + }); +} + +export function buildAccessAmbiguityCheckpoint(input: AccessAmbiguityCheckpointInput): QuestionRecord { + return buildRequiredCheckpoint({ + ...input, + stage: "PRODUCTION_SEARCH", + code: "TERMS_OR_ACCESS_UNCLEAR", + title: "Access or terms clarification", + question: `${input.databaseId} reported an access or terms ambiguity: ${input.issue}`, + type: "single_choice", + options: [ + { value: "researcher_reviewed_and_permitted", label: "Reviewed and permitted", description: "Continue only after the researcher confirms the lawful route." }, + { value: "use_metadata_only", label: "Use metadata only", description: "Do not attempt licensed full-text acquisition." }, + { value: "suspend", label: "Suspend", description: "Keep the run blocked." } + ], + scope: "evidence", + family: "evidence", + epistemicBasis: "researcher_knowledge", + displayReason: "LongTable must not infer access rights or terms acceptance.", + rationale: ["Credentials and browser session material are never requested or persisted."] + }); +} + +export function buildFulltextPlanCheckpoint(input: FulltextPlanCheckpointInput): QuestionRecord { + return buildRequiredCheckpoint({ + ...input, + stage: "FULLTEXT_PLAN_CHECKPOINT", + code: "FULLTEXT_PLAN", + title: "Full-text acquisition plan", + question: `${input.candidateCount} records need full-text resolution through ${input.lawfulRoutes.join(", ") || "no configured route"}; ${input.unresolvedCount} currently have no route.`, + type: "single_choice", + options: [ + { value: "approve_lawful_routes", label: "Approve lawful routes", description: "Acquire only permitted PDFs into Research PDF Vault.", recommended: true }, + { value: "modify_routes", label: "Modify routes", description: "Change resolver or manual-action handling." }, + { value: "suspend", label: "Suspend", description: "Keep acquisition blocked." } + ], + scope: "evidence", + family: "evidence", + epistemicBasis: "mixed", + displayReason: "Potential-inclusion records are screened before PDF acquisition.", + rationale: ["Research PDF Vault is the only canonical PDF store."] + }); +} + +export function buildCorpusFreezeCheckpoint(input: CorpusFreezeCheckpointInput): QuestionRecord { + return buildRequiredCheckpoint({ + ...input, + stage: "CORPUS_FREEZE_CHECKPOINT", + code: "CORPUS_FREEZE_REQUIRED", + title: "Corpus freeze", + question: `${input.includedCount} studies are included and ${input.unresolvedCount} remain unresolved. Freeze the corpus for analysis?`, + type: "single_choice", + options: [ + { value: "freeze_corpus", label: "Freeze corpus", description: "Authorize analysis using the recorded included set.", recommended: input.unresolvedCount === 0 }, + { value: "resolve_remaining", label: "Resolve remaining records", description: "Return to full-text screening." }, + { value: "revise_screening_rules", label: "Revise screening rules", description: "Create a material protocol amendment before rescreening." } + ], + scope: "evidence", + family: "evidence", + epistemicBasis: "mixed", + displayReason: "Analysis requires an explicit, auditable corpus commitment.", + rationale: ["No unresolved record is silently dropped."] + }); +} + +export function buildScreeningConflictCheckpoint(input: ScreeningConflictCheckpointInput): QuestionRecord { + return buildRequiredCheckpoint({ + ...input, + stage: "FULLTEXT_SCREENING", + code: "HUMAN_AI_SCREENING_CONFLICT", + title: "Screening conflict", + question: `${input.conflict}. Affected records: ${input.paperIds.join(", ")}.`, + type: "single_choice", + options: [ + { value: "open_adjudication", label: "Open adjudication", description: "Review the evidence and write a new human adjudication decision.", recommended: true }, + { value: "revise_rule", label: "Revise screening rule", description: "Create a protocol amendment before applying a changed rule." }, + { value: "suspend", label: "Suspend", description: "Keep affected records unresolved." } + ], + scope: "construct", + family: "coding", + epistemicBasis: "mixed", + displayReason: "Human–AI disagreement cannot be resolved by an implicit model default.", + rationale: ["Repeated conflicts are bundled into one researcher checkpoint."] + }); +} + +export function buildAnalysisMethodCheckpoint(input: AnalysisMethodCheckpointInput): QuestionRecord { + return buildRequiredCheckpoint({ + ...input, + stage: "ANALYSIS", + code: "ANALYSIS_METHOD_CHANGE", + title: "Analysis-method commitment", + question: `Proposed method: ${input.proposedMethod}. Rationale: ${input.rationale}.`, + type: "single_choice", + options: [ + { value: "approve_method", label: "Approve method", description: "Record the method commitment and continue.", recommended: true }, + { value: "revise_method", label: "Revise method", description: "Return to the analysis plan without changing the frozen corpus." }, + { value: "cancel", label: "Cancel", description: "Keep analysis blocked." } + ], + scope: "method", + family: "method", + epistemicBasis: "mixed", + displayReason: "Analysis-model selection is a protected researcher decision.", + rationale: ["The renderer cannot claim an unapproved analysis."] + }); +} diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index 8aacbe1..8ad0490 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -8,3 +8,4 @@ export * from "./protocol.js"; export * from "./workflow-types.js"; export * from "./workflow-state.js"; export * from "./project-store.js"; +export * from "./checkpoints.js"; diff --git a/scripts/smoke-institutional-checkpoints.mjs b/scripts/smoke-institutional-checkpoints.mjs new file mode 100644 index 0000000..5862c7a --- /dev/null +++ b/scripts/smoke-institutional-checkpoints.mjs @@ -0,0 +1,103 @@ +import { strict as assert } from "node:assert"; +import { resolve } from "node:path"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const research = await import(resolve(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); + +function assertRequiredMcpCheckpoint(record, expected) { + assert.equal(record.status, "pending"); + assert.equal(record.hardStop, true); + assert.equal(record.hardStopScope, expected.scope); + assert.equal(record.commitmentFamily, expected.family); + assert.equal(record.prompt.required, true); + assert.deepEqual(record.prompt.preferredSurfaces, ["mcp_elicitation", "numbered"]); + assert.equal(record.transportStatus.surface, "mcp_elicitation"); + assert.equal(record.transportStatus.status, "not_attempted"); +} + +const strategyInput = { + runId: "run-1", + protocolReference: "draft-7", + reviewType: "systematic_review", + objective: "Map how hackathon protocols develop workplace competencies.", + databaseGroups: { + core: ["Web of Science", "Scopus"], + domain: ["ERIC", "PsycINFO"], + complementary: ["KCI", "RISS"], + normalization: ["Crossref", "OpenAlex"], + fulltextResolution: ["PSU Libraries", "Unpaywall"] + }, + filters: { + years: [2015, 2026], + languages: ["English", "Korean"], + publicationTypes: ["journal article", "conference paper"] + }, + recallPrecisionPosture: "recall_first", + accessLimitations: ["Researcher login and MFA remain manual."], + createdAt: "2026-08-06T03:00:00.000Z" +}; + +const strategy = research.buildSearchStrategyCheckpoint(strategyInput); +assertRequiredMcpCheckpoint(strategy, { scope: "method", family: "method" }); +assert.equal(strategy.prompt.type, "single_choice"); +assert.equal(strategy.prompt.checkpointKey, "institutional:run-1:draft-7:SETUP:SEARCH_STRATEGY"); +assert.deepEqual(strategy.prompt.options.map((option) => option.value), [ + "pilot", + "modify_databases", + "modify_scope", + "open_detailed_wizard", + "cancel" +]); +assert.equal(strategy.prompt.options.filter((option) => option.recommended).length, 1); +assert.match(strategy.prompt.question, /Web of Science/); +assert.match(strategy.prompt.question, /2015–2026/); +assert.equal(research.buildSearchStrategyCheckpoint(strategyInput).id, strategy.id); + +const databaseSelection = research.buildDatabaseSelectionCheckpoint({ + runId: "run-1", + protocolReference: "draft-7", + availableDatabases: [ + { id: "wos", label: "Web of Science", group: "core", available: true }, + { id: "scopus", label: "Scopus", group: "core", available: true }, + { id: "psycinfo", label: "PsycINFO", group: "domain", available: false, limitation: "Not in current institution profile." } + ], + selectedDatabaseIds: ["wos", "scopus"], + createdAt: "2026-08-06T03:01:00.000Z" +}); +assertRequiredMcpCheckpoint(databaseSelection, { scope: "method", family: "method" }); +assert.equal(databaseSelection.prompt.type, "multi_choice"); +assert.deepEqual(databaseSelection.prompt.options.map((option) => option.value), ["wos", "scopus"]); +assert.match(databaseSelection.prompt.rationale.join(" "), /progressive disclosure/i); + +const pilotFreeze = research.buildPilotFreezeCheckpoint({ + runId: "run-1", + protocolRevisionId: "protocol-1", + databaseYields: { wos: 120, scopus: 98 }, + estimatedOverlap: 0.31, + missingAbstractRate: 0.08, + relevanceSample: { relevant: 16, reviewed: 20 }, + resultCaps: [], + queryProblems: ["Korean synonym coverage needs confirmation."], + recommendedAmendments: ["Add 직무역량 as a Korean synonym."], + createdAt: "2026-08-06T03:02:00.000Z" +}); +assertRequiredMcpCheckpoint(pilotFreeze, { scope: "method", family: "method" }); +assert.equal(pilotFreeze.prompt.checkpointKey, "institutional:run-1:protocol-1:PROTOCOL_CHECKPOINT:PILOT_FREEZE"); +assert.deepEqual(pilotFreeze.prompt.options.map((option) => option.value), ["freeze_production_protocol", "revise_protocol", "cancel"]); +assert.match(pilotFreeze.prompt.question, /120/); +assert.match(pilotFreeze.prompt.question, /8\.0%/); + +const specialized = [ + [research.buildAccessAmbiguityCheckpoint({ runId: "run-1", protocolRevisionId: "protocol-1", databaseId: "wos", issue: "Terms changed", createdAt: "2026-08-06T03:03:00.000Z" }), "evidence", "evidence"], + [research.buildFulltextPlanCheckpoint({ runId: "run-1", protocolRevisionId: "protocol-1", candidateCount: 42, lawfulRoutes: ["PSU Libraries"], unresolvedCount: 3, createdAt: "2026-08-06T03:04:00.000Z" }), "evidence", "evidence"], + [research.buildCorpusFreezeCheckpoint({ runId: "run-1", protocolRevisionId: "protocol-1", includedCount: 27, unresolvedCount: 1, createdAt: "2026-08-06T03:05:00.000Z" }), "evidence", "evidence"], + [research.buildScreeningConflictCheckpoint({ runId: "run-1", protocolRevisionId: "protocol-1", paperIds: ["paper-1", "paper-2"], conflict: "Human include versus AI exclude", createdAt: "2026-08-06T03:06:00.000Z" }), "construct", "coding"], + [research.buildAnalysisMethodCheckpoint({ runId: "run-1", protocolRevisionId: "protocol-1", proposedMethod: "validated semantic matching", rationale: "Bridge Korean and English constructs", createdAt: "2026-08-06T03:07:00.000Z" }), "method", "method"] +]; + +for (const [record, scope, family] of specialized) { + assertRequiredMcpCheckpoint(record, { scope, family }); + assert(record.prompt.options.length >= 2); +} + +console.log("institutional checkpoint contracts smoke passed"); From b7216c5ccfb3004cabdcb3be539e4ef68f48db16 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:38:52 +0900 Subject: [PATCH 08/25] feat(research): expose one integrated research surface --- docs/LONGTABLE-COMMAND-SURFACE.md | 18 +++++ packages/longtable-mcp/src/server.ts | 32 +++++++- packages/longtable/README.md | 8 ++ packages/longtable/src/cli.ts | 68 ++++++++++++---- packages/longtable/src/project-session.ts | 6 ++ scripts/smoke-institutional-checkpoints.mjs | 86 ++++++++++++++++++++- 6 files changed, 201 insertions(+), 17 deletions(-) diff --git a/docs/LONGTABLE-COMMAND-SURFACE.md b/docs/LONGTABLE-COMMAND-SURFACE.md index 231beba..24336da 100644 --- a/docs/LONGTABLE-COMMAND-SURFACE.md +++ b/docs/LONGTABLE-COMMAND-SURFACE.md @@ -180,6 +180,24 @@ Tmux is not a LongTable core requirement. If an OMX-style Codex popup transport is added, it must be documented as optional, attached-tmux-only, and backed by the standard fallback path. +## LongTable Research Surface + +`longtable research` is the single public command surface for the integrated +institutional-database workflow. It owns project initialization, readiness, +protocol checkpoints, corpus processing, Research PDF Vault references, +screening, reports, and manuscript packaging behind one capability tree. + +```bash +longtable research doctor --json +longtable research init --cwd "" --json +longtable research status --cwd "" --json +``` + +`longtable scholar-research` is retained only as a deprecated compatibility +alias. Required decisions use MCP elicitation first and numbered fallback +second. A fallback reuses the same pending QuestionRecord by checkpoint key; +it never creates an implicit answer. + ## Supporting Commands ```bash diff --git a/packages/longtable-mcp/src/server.ts b/packages/longtable-mcp/src/server.ts index aa95d37..1bf8d4e 100644 --- a/packages/longtable-mcp/src/server.ts +++ b/packages/longtable-mcp/src/server.ts @@ -12,6 +12,7 @@ import { classifyCheckpointTrigger } from "@longtable/checkpoints"; import { renderQuestionRecordInput } from "@longtable/provider-claude"; import { renderQuestionRecordPrompt } from "@longtable/provider-codex"; import type { + HardStopScope, ProviderKind, QuestionCommitmentFamily, QuestionEpistemicBasis, @@ -259,6 +260,15 @@ const epistemicBasisSchema = z.enum([ "mixed" ]); +const hardStopScopeSchema = z.enum([ + "research_question", + "scope", + "construct", + "method", + "evidence", + "protected_decision" +]); + const firstResearchShapeSchema = z.object({ handle: z.string().min(1), currentGoal: z.string().min(1), @@ -2236,11 +2246,13 @@ export function createLongTableMcpServer(): McpServer { displayReason: z.string().optional(), provider: z.enum(["codex", "claude"]).optional(), required: z.boolean().optional(), + hardStop: z.boolean().optional(), + hardStopScope: hardStopScopeSchema.optional(), commitmentFamily: commitmentFamilySchema.optional(), epistemicBasis: epistemicBasisSchema.optional() }) }, - async ({ cwd: inputCwd, prompt, title, question, type, checkpointKey, options, allowOther, otherLabel, displayReason, provider, required, commitmentFamily, epistemicBasis }) => { + async ({ cwd: inputCwd, prompt, title, question, type, checkpointKey, options, allowOther, otherLabel, displayReason, provider, required, hardStop, hardStopScope, commitmentFamily, epistemicBasis }) => { try { const context = await requireContext(inputCwd); const result = await createWorkspaceQuestion({ @@ -2256,6 +2268,8 @@ export function createLongTableMcpServer(): McpServer { displayReason, provider, required, + hardStop, + hardStopScope: hardStopScope as HardStopScope | undefined, commitmentFamily: commitmentFamily as QuestionCommitmentFamily | undefined, epistemicBasis: epistemicBasis as QuestionEpistemicBasis | undefined }); @@ -2286,12 +2300,14 @@ export function createLongTableMcpServer(): McpServer { displayReason: z.string().optional(), provider: z.enum(["codex", "claude"]).default("codex"), required: z.boolean().optional(), + hardStop: z.boolean().optional(), + hardStopScope: hardStopScopeSchema.optional(), commitmentFamily: commitmentFamilySchema.optional(), epistemicBasis: epistemicBasisSchema.optional(), fallbackOnly: z.boolean().default(false).describe("Create and render the checkpoint without calling MCP elicitation.") }) }, - async ({ cwd: inputCwd, prompt, title, question, type, checkpointKey, options, allowOther, otherLabel, displayReason, provider, required, commitmentFamily, epistemicBasis, fallbackOnly }) => { + async ({ cwd: inputCwd, prompt, title, question, type, checkpointKey, options, allowOther, otherLabel, displayReason, provider, required, hardStop, hardStopScope, commitmentFamily, epistemicBasis, fallbackOnly }) => { try { const context = await requireContext(inputCwd); const created = await createWorkspaceQuestion({ @@ -2307,6 +2323,8 @@ export function createLongTableMcpServer(): McpServer { displayReason, provider, required, + hardStop, + hardStopScope: hardStopScope as HardStopScope | undefined, commitmentFamily: commitmentFamily as QuestionCommitmentFamily | undefined, epistemicBasis: epistemicBasis as QuestionEpistemicBasis | undefined }); @@ -2460,7 +2478,15 @@ export async function runStdioServer(): Promise { export async function runLongTableMcpCli(argv = process.argv): Promise { if (argv.includes("--self-test")) { - console.log(JSON.stringify({ name: SERVER_NAME, version: SERVER_VERSION, tools: TOOL_NAMES }, null, 2)); + console.log(JSON.stringify({ + name: SERVER_NAME, + version: SERVER_VERSION, + tools: TOOL_NAMES, + institutionalResearch: { + hardStopInputs: true, + fallbackSurfaces: ["mcp_elicitation", "numbered"] + } + }, null, 2)); return; } diff --git a/packages/longtable/README.md b/packages/longtable/README.md index 0cb1c95..4eb50a7 100644 --- a/packages/longtable/README.md +++ b/packages/longtable/README.md @@ -81,9 +81,17 @@ longtable question --prompt "" longtable decide --question --answer longtable spec read --cwd "" longtable search --query "" +longtable research doctor +longtable research init --cwd "" +longtable research status --cwd "" longtable panel --prompt "review this measurement plan" --json ``` +`longtable research` is the single public surface for institutional-database +search, protocol freeze, corpus, PDF Vault, screening, reporting, and manuscript +packaging. The older `longtable scholar-research` command remains a deprecated +compatibility alias for existing automation. + `longtable start` remains available for scripted workspace creation with `--no-interview --json`, but it is not the primary research-start surface. diff --git a/packages/longtable/src/cli.ts b/packages/longtable/src/cli.ts index 02bab30..e99f4c8 100644 --- a/packages/longtable/src/cli.ts +++ b/packages/longtable/src/cli.ts @@ -37,6 +37,9 @@ import { publisherConfigs, runResearchSearch, SEARCH_SOURCES, + buildResearchProjectLayout, + readLatestProtocolRevision, + writeResearchProjectScaffold, writeScholarResearchRunScaffold, summarizeConfiguredPublisherAccess, type EvidenceRun, @@ -412,9 +415,10 @@ function usage(): string { " longtable access doctor [--doi ] [--publisher auto|elsevier|springer_nature|wiley|taylor_francis|all] [--json]", " longtable access probe --doi [--publisher auto|elsevier|springer_nature|wiley|taylor_francis] [--json]", " longtable search --query [--intent literature|theory|measurement|citation|metadata|venue] [--field ] [--source all|crossref,arxiv,openalex,semantic_scholar,pubmed,eric,doaj] [--must ] [--exclude ] [--limit ] [--allow-partial] [--publisher-access] [--record] [--cwd ] [--json]", - " longtable scholar-research doctor [--json]", - " longtable scholar-research scaffold [--cwd ] [--run-id ] [--json]", - " longtable scholar-research smoke-fixture [--json]", + " longtable research doctor [--json]", + " longtable research init [--cwd ] [--json]", + " longtable research status [--cwd ] [--json]", + " longtable scholar-research ... [deprecated compatibility alias]", " longtable sentinel --prompt [--cwd ] [--json] [--record]", " longtable ask [--prompt ] [--print] [--json] [--setup ] [--cwd ]", " longtable clarify --prompt [--provider codex|claude] [--required|--advisory] [--print] [--cwd ] [--json] [--force]", @@ -465,7 +469,7 @@ function parseArgs(argv: string[]): ParsedArgs { const modeCommand = command && VALID_MODES.has(command as InteractionMode); const directCommand = - command && ["init", "setup", "start", "resume", "doctor", "status", "audit", "roles", "show", "install", "mcp", "codex", "claude", "ask", "clarify", "question", "clear-question", "prune-questions", "panel", "handoff", "decide", "sentinel", "access", "search", "scholar-research", "spec"].includes(command); + command && ["init", "setup", "start", "resume", "doctor", "status", "audit", "roles", "show", "install", "mcp", "codex", "claude", "ask", "clarify", "question", "clear-question", "prune-questions", "panel", "handoff", "decide", "sentinel", "access", "search", "research", "scholar-research", "spec"].includes(command); let startIndex = 1; if (modeCommand) { @@ -473,7 +477,7 @@ function parseArgs(argv: string[]): ParsedArgs { startIndex = 1; } else if (command === "codex" || command === "claude" || command === "mcp") { startIndex = 2; - } else if ((command === "access" || command === "search" || command === "scholar-research" || command === "spec" || command === "panel") && maybeSubcommand && !maybeSubcommand.startsWith("--")) { + } else if ((command === "access" || command === "search" || command === "research" || command === "scholar-research" || command === "spec" || command === "panel") && maybeSubcommand && !maybeSubcommand.startsWith("--")) { subcommand = maybeSubcommand; startIndex = 2; } else if (command === "audit" && maybeSubcommand && !maybeSubcommand.startsWith("--")) { @@ -4367,18 +4371,38 @@ async function runSearch(subcommand: string | undefined, args: Record + args: Record, + compatibilityAlias = false ): Promise { const json = args.json === true; if (subcommand === "doctor" || subcommand === "status" || !subcommand) { const readiness = assessScholarResearchReadiness(env); + const workingDirectory = typeof args.cwd === "string" ? args.cwd : cwd(); + const layout = buildResearchProjectLayout(workingDirectory); + let latestProtocolRevision: Awaited> | undefined; + if (subcommand === "status") { + try { + latestProtocolRevision = await readLatestProtocolRevision(layout); + } catch { + latestProtocolRevision = undefined; + } + } + const result = { + ...readiness, + surface: "longtable-research", + ...(compatibilityAlias ? { compatibilityAlias: "scholar-research", deprecated: true } : {}), + ...(subcommand === "status" ? { layout, latestProtocolRevision } : {}) + }; if (json) { - console.log(JSON.stringify(readiness, null, 2)); + console.log(JSON.stringify(result, null, 2)); return; } - console.log("LongTable scholar-research doctor"); + console.log("LongTable Research"); + if (compatibilityAlias) { + console.log("- scholar-research is a deprecated compatibility alias"); + } for (const connector of readiness.connectors) { const missing = connector.missingEnv.length > 0 ? ` (missing ${connector.missingEnv.join(", ")})` : ""; console.log(`- ${connector.name}: ${connector.status}${missing}`); @@ -4387,7 +4411,20 @@ async function runScholarResearch( return; } - if (subcommand === "scaffold") { + if (subcommand === "init" || (!compatibilityAlias && subcommand === "scaffold")) { + const workingDirectory = typeof args.cwd === "string" ? args.cwd : cwd(); + const layout = await writeResearchProjectScaffold(workingDirectory); + const result = { surface: "longtable-research", layout }; + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log("LongTable Research project initialized"); + console.log(`- root: ${layout.root}`); + return; + } + + if (compatibilityAlias && subcommand === "scaffold") { const workingDirectory = typeof args.cwd === "string" ? args.cwd : cwd(); const runId = typeof args["run-id"] === "string" ? args["run-id"] : undefined; const scaffold = await writeScholarResearchRunScaffold({ @@ -4395,7 +4432,7 @@ async function runScholarResearch( ...(runId ? { runId } : {}) }); if (json) { - console.log(JSON.stringify(scaffold, null, 2)); + console.log(JSON.stringify({ ...scaffold, surface: "longtable-research", compatibilityAlias: "scholar-research", deprecated: true }, null, 2)); return; } console.log("LongTable scholar-research scaffold"); @@ -4417,7 +4454,7 @@ async function runScholarResearch( return; } - throw new Error(`Unknown scholar-research subcommand: ${subcommand}`); + throw new Error(`Unknown LongTable Research subcommand: ${subcommand}`); } async function requireWorkspaceContext(args: Record): Promise { @@ -5744,8 +5781,13 @@ async function main(): Promise { return; } + if (command === "research") { + await runLongTableResearch(subcommand, values); + return; + } + if (command === "scholar-research") { - await runScholarResearch(subcommand, values); + await runLongTableResearch(subcommand, values, true); return; } diff --git a/packages/longtable/src/project-session.ts b/packages/longtable/src/project-session.ts index f5f86fc..24e940e 100644 --- a/packages/longtable/src/project-session.ts +++ b/packages/longtable/src/project-session.ts @@ -3723,6 +3723,12 @@ export async function createWorkspaceQuestion(options: { studyContract: state.studyContract }); const checkpointKey = options.checkpointKey ?? trigger.signal.checkpointKey; + const existingPending = (state.questionLog ?? []).find((record) => + record.status === "pending" && record.prompt.checkpointKey === checkpointKey + ); + if (existingPending) { + return { question: existingPending, state }; + } const promptType = options.type ?? "single_choice"; const createdAt = nowIso(); const title = options.title ?? questionTitleForCheckpoint(trigger.family, checkpointKey); diff --git a/scripts/smoke-institutional-checkpoints.mjs b/scripts/smoke-institutional-checkpoints.mjs index 5862c7a..5353b9a 100644 --- a/scripts/smoke-institutional-checkpoints.mjs +++ b/scripts/smoke-institutional-checkpoints.mjs @@ -1,8 +1,14 @@ import { strict as assert } from "node:assert"; -import { resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const cliPath = join(repoRoot, "packages", "longtable", "dist", "cli.js"); +const mcpPath = join(repoRoot, "packages", "longtable-mcp", "dist", "server.js"); const research = await import(resolve(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); +const longtable = await import(resolve(repoRoot, "packages", "longtable", "dist", "index.js")); function assertRequiredMcpCheckpoint(record, expected) { assert.equal(record.status, "pending"); @@ -100,4 +106,82 @@ for (const [record, scope, family] of specialized) { assert(record.prompt.options.length >= 2); } +function runCli(args, cwd = repoRoot) { + return execFileSync("node", [cliPath, ...args], { cwd, encoding: "utf8" }); +} + +const canonicalDoctor = JSON.parse(runCli(["research", "doctor", "--json"])); +const compatibilityDoctor = JSON.parse(runCli(["scholar-research", "doctor", "--json"])); +assert.equal(canonicalDoctor.surface, "longtable-research"); +assert.equal(canonicalDoctor.compatibilityAlias, undefined); +assert.equal(compatibilityDoctor.surface, "longtable-research"); +assert.equal(compatibilityDoctor.compatibilityAlias, "scholar-research"); +assert.equal(compatibilityDoctor.deprecated, true); +assert.deepEqual(compatibilityDoctor.connectors, canonicalDoctor.connectors); + +const mcpSelfTest = JSON.parse(execFileSync("node", [mcpPath, "--self-test"], { cwd: repoRoot, encoding: "utf8" })); +assert(mcpSelfTest.tools.includes("elicit_question")); +assert.equal(mcpSelfTest.institutionalResearch.hardStopInputs, true); +assert.deepEqual(mcpSelfTest.institutionalResearch.fallbackSurfaces, ["mcp_elicitation", "numbered"]); + +const workspaceRoot = mkdtempSync(join(tmpdir(), "longtable-institutional-checkpoint-")); +try { + const initialized = JSON.parse(runCli(["research", "init", "--cwd", workspaceRoot, "--json"], workspaceRoot)); + assert.equal(initialized.surface, "longtable-research"); + assert.equal(initialized.layout.root, workspaceRoot); + + const setupPath = join(workspaceRoot, "setup.json"); + const runtimePath = join(workspaceRoot, "runtime.toml"); + runCli([ + "setup", + "--provider", "codex", + "--install-scope", "none", + "--surfaces", "cli_only", + "--intervention", "balanced", + "--workspace", "later", + "--setup-path", setupPath, + "--runtime-path", runtimePath, + "--json" + ], workspaceRoot); + runCli([ + "start", + "--setup", setupPath, + "--path", workspaceRoot, + "--name", "Institutional Checkpoint Smoke", + "--goal", "Verify checkpoint reuse", + "--blocker", "Production requires a protocol decision", + "--research-object", "study_design", + "--gap-risk", "known_gap", + "--protected-decision", "method", + "--perspectives", "auto", + "--disagreement", "show_on_conflict", + "--no-interview", + "--json" + ], workspaceRoot); + + const context = await longtable.loadProjectContextFromDirectory(workspaceRoot); + assert(context); + const questionInput = { + context, + prompt: "Freeze the institutional search protocol before production.", + title: "Pilot freeze", + question: "Freeze this protocol?", + type: "single_choice", + checkpointKey: "institutional:run-1:protocol-1:PROTOCOL_CHECKPOINT:PILOT_FREEZE", + questionOptions: [{ value: "freeze", label: "Freeze" }, { value: "revise", label: "Revise" }], + required: true, + hardStop: true, + hardStopScope: "method", + commitmentFamily: "method", + epistemicBasis: "mixed" + }; + const first = await longtable.createWorkspaceQuestion(questionInput); + const fallbackRetry = await longtable.createWorkspaceQuestion(questionInput); + assert.equal(fallbackRetry.question.id, first.question.id); + const state = await longtable.loadWorkspaceState(context); + assert.equal(state.questionLog.filter((entry) => entry.prompt.checkpointKey === questionInput.checkpointKey).length, 1); +} finally { + rmSync(workspaceRoot, { recursive: true, force: true }); +} + console.log("institutional checkpoint contracts smoke passed"); From e9510ab3a546bae5c86af22277541516f1fe57ab Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:42:35 +0900 Subject: [PATCH 09/25] feat(research): parse scholarly exports deterministically --- package.json | 3 +- .../fixtures/exports/crossref.json | 17 + .../fixtures/exports/expected-records.json | 8 + .../fixtures/exports/pubmed.nbib | 12 + .../fixtures/exports/scopus.ris | 13 + .../fixtures/exports/wos.csv | 3 + .../fixtures/exports/wos.xls | 7 + .../src/export-parsers.ts | 429 ++++++++++++++++++ .../longtable-scholar-research/src/index.ts | 1 + scripts/smoke-export-parsers.mjs | 72 +++ 10 files changed, 564 insertions(+), 1 deletion(-) create mode 100644 packages/longtable-scholar-research/fixtures/exports/crossref.json create mode 100644 packages/longtable-scholar-research/fixtures/exports/expected-records.json create mode 100644 packages/longtable-scholar-research/fixtures/exports/pubmed.nbib create mode 100644 packages/longtable-scholar-research/fixtures/exports/scopus.ris create mode 100644 packages/longtable-scholar-research/fixtures/exports/wos.csv create mode 100644 packages/longtable-scholar-research/fixtures/exports/wos.xls create mode 100644 packages/longtable-scholar-research/src/export-parsers.ts create mode 100644 scripts/smoke-export-parsers.mjs diff --git a/package.json b/package.json index ace6a94..31001ce 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "smoke:scholar-research": "node scripts/smoke-scholar-research.mjs", "smoke:institutional-workflow": "node scripts/smoke-institutional-workflow.mjs", "smoke:institutional-checkpoints": "node scripts/smoke-institutional-checkpoints.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run typecheck", + "smoke:export-parsers": "node scripts/smoke-export-parsers.mjs", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, diff --git a/packages/longtable-scholar-research/fixtures/exports/crossref.json b/packages/longtable-scholar-research/fixtures/exports/crossref.json new file mode 100644 index 0000000..32623f0 --- /dev/null +++ b/packages/longtable-scholar-research/fixtures/exports/crossref.json @@ -0,0 +1,17 @@ +{ + "message": { + "items": [ + { + "DOI": "10.1000/CHALLENGE.4", + "title": ["Challenge weeks as accelerated workplace learning"], + "author": [ + { "given": "Sora", "family": "Choi" } + ], + "published": { "date-parts": [[2021, 6, 1]] }, + "abstract": "An evaluation of a challenge-week protocol.", + "container-title": ["Studies in Continuing Education"], + "URL": "https://doi.org/10.1000/CHALLENGE.4" + } + ] + } +} diff --git a/packages/longtable-scholar-research/fixtures/exports/expected-records.json b/packages/longtable-scholar-research/fixtures/exports/expected-records.json new file mode 100644 index 0000000..1bf0588 --- /dev/null +++ b/packages/longtable-scholar-research/fixtures/exports/expected-records.json @@ -0,0 +1,8 @@ +[ + { "sourceRecordId": "WOS:0001", "title": "Hackathons, capability building, and work", "year": 2024, "doi": "10.1000/hack.1", "authorCount": 2 }, + { "sourceRecordId": "WOS:0002", "title": "직무역량 해커톤 연구", "year": 2025, "authorCount": 2 }, + { "sourceRecordId": "SCOPUS:002", "title": "Design sprints for professional learning", "year": 2023, "doi": "10.1000/sprint.2", "authorCount": 2 }, + { "sourceRecordId": "39000001", "title": "Health hackathons and interprofessional competence", "year": 2022, "doi": "10.1000/health.3", "authorCount": 2 }, + { "sourceRecordId": "10.1000/challenge.4", "title": "Challenge weeks as accelerated workplace learning", "year": 2021, "doi": "10.1000/challenge.4", "authorCount": 1 }, + { "sourceRecordId": "WOS:0005", "title": "Innovation camps and employee capability", "year": 2020, "doi": "10.1000/camp.5", "authorCount": 2 } +] diff --git a/packages/longtable-scholar-research/fixtures/exports/pubmed.nbib b/packages/longtable-scholar-research/fixtures/exports/pubmed.nbib new file mode 100644 index 0000000..a219bef --- /dev/null +++ b/packages/longtable-scholar-research/fixtures/exports/pubmed.nbib @@ -0,0 +1,12 @@ +PMID- 39000001 +TI - Health hackathons and interprofessional competence +FAU - Lee, Hana +FAU - Garcia, Mateo +DP - 2022 Jan +AB - A simulation-based hackathon was evaluated across health professions. + Team competence was assessed before and after the event. +JT - Medical Education +LID - 10.1000/HEALTH.3 [doi] +OT - hackathon +OT - interprofessional competence + diff --git a/packages/longtable-scholar-research/fixtures/exports/scopus.ris b/packages/longtable-scholar-research/fixtures/exports/scopus.ris new file mode 100644 index 0000000..0d73e13 --- /dev/null +++ b/packages/longtable-scholar-research/fixtures/exports/scopus.ris @@ -0,0 +1,13 @@ +TY - JOUR +TI - Design sprints for professional learning +AU - Park, Jisoo +AU - Smith, Alex +PY - 2023 +AB - This study examines intensive design challenges + as a workplace learning protocol. +DO - https://doi.org/10.1000/SPRINT.2 +JO - Human Resource Development Review +AN - SCOPUS:002 +KW - design sprint +KW - professional learning +ER - diff --git a/packages/longtable-scholar-research/fixtures/exports/wos.csv b/packages/longtable-scholar-research/fixtures/exports/wos.csv new file mode 100644 index 0000000..a219155 --- /dev/null +++ b/packages/longtable-scholar-research/fixtures/exports/wos.csv @@ -0,0 +1,3 @@ +Title,Authors,Publication Year,Abstract,DOI,Source Title,Accession Number,Author Keywords +"Hackathons, capability building, and work","Kim, Mina; You, Hosung",2024,"A mixed-method study of workplace competency development.",10.1000/HACK.1,Journal of Workplace Learning,WOS:0001,"hackathon; competency" +"직무역량 해커톤 연구","유호성; 김민아",2025,"한국 조직의 집중형 문제해결 프로그램을 분석하였다.",,한국직업교육연구,WOS:0002,"직무역량; 해커톤" diff --git a/packages/longtable-scholar-research/fixtures/exports/wos.xls b/packages/longtable-scholar-research/fixtures/exports/wos.xls new file mode 100644 index 0000000..7710357 --- /dev/null +++ b/packages/longtable-scholar-research/fixtures/exports/wos.xls @@ -0,0 +1,7 @@ + + + + TitleAuthorsPublication YearAbstractDOISource TitleAccession Number + Innovation camps and employee capabilityNguyen, Linh; Brown, Taylor2020A longitudinal evaluation of an innovation camp.10.1000/CAMP.5Adult Education QuarterlyWOS:0005 +
+
diff --git a/packages/longtable-scholar-research/src/export-parsers.ts b/packages/longtable-scholar-research/src/export-parsers.ts new file mode 100644 index 0000000..32815ed --- /dev/null +++ b/packages/longtable-scholar-research/src/export-parsers.ts @@ -0,0 +1,429 @@ +import { createHash } from "node:crypto"; + +export const EXPORT_FORMATS = ["csv", "ris", "nbib", "json", "spreadsheet_xml"] as const; +export type ExportFormat = typeof EXPORT_FORMATS[number]; + +export interface CanonicalBibliographicRecord { + readonly title: string; + readonly authors: readonly string[]; + readonly year?: number; + readonly abstract?: string; + readonly doi?: string; + readonly venue?: string; + readonly keywords: readonly string[]; + readonly sourceDatabase: string; + readonly sourceRecordId: string; + readonly sourceRow: number; + readonly exportArtifactSha256: string; +} + +export type ExportParseRejectionReason = + | "missing_title" + | "malformed_row" + | "malformed_export" + | "empty_export"; + +export interface ExportParseRejection { + readonly reason: ExportParseRejectionReason; + readonly sourceRow?: number; + readonly sourceRecordId?: string; + readonly detail: string; +} + +export interface ParsedExport { + readonly format: ExportFormat; + readonly artifactSha256: string; + readonly records: readonly CanonicalBibliographicRecord[]; + readonly rejections: readonly ExportParseRejection[]; +} + +export interface ParserContext { + readonly database: string; + readonly artifactSha256: string; +} + +export interface ParseExportArtifactInput { + readonly filename: string; + readonly format: ExportFormat | string; + readonly database: string; + readonly content: string | Buffer; +} + +interface RecordFields { + readonly title?: string; + readonly authors?: readonly string[]; + readonly year?: string | number; + readonly abstract?: string; + readonly doi?: string; + readonly venue?: string; + readonly keywords?: readonly string[]; + readonly sourceRecordId?: string; +} + +function decodeEntities(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'|'/g, "'") + .replace(/&#(\d+);/g, (_match, code: string) => String.fromCodePoint(Number(code))); +} + +function cleanText(value: unknown): string | undefined { + if (typeof value !== "string" && typeof value !== "number") { + return undefined; + } + const cleaned = decodeEntities(String(value)) + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); + return cleaned || undefined; +} + +function normalizeDoi(value: unknown): string | undefined { + const cleaned = cleanText(value); + if (!cleaned) return undefined; + const normalized = cleaned + .replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, "") + .replace(/^doi:\s*/i, "") + .replace(/\s*\[doi\]\s*$/i, "") + .trim() + .toLowerCase(); + return normalized || undefined; +} + +function parseYear(value: unknown): number | undefined { + const match = cleanText(value)?.match(/(?:19|20)\d{2}/); + return match ? Number(match[0]) : undefined; +} + +function cleanList(values: readonly unknown[] | undefined): string[] { + return (values ?? []).map(cleanText).filter((value): value is string => Boolean(value)); +} + +function splitList(value: unknown): string[] { + const cleaned = cleanText(value); + return cleaned ? cleaned.split(/\s*;\s*/).map((entry) => entry.trim()).filter(Boolean) : []; +} + +function buildRecord( + fields: RecordFields, + context: ParserContext, + sourceRow: number +): { readonly record?: CanonicalBibliographicRecord; readonly rejection?: ExportParseRejection } { + const title = cleanText(fields.title); + const sourceRecordId = cleanText(fields.sourceRecordId) ?? `${context.database}:row:${sourceRow}`; + if (!title) { + return { + rejection: { + reason: "missing_title", + sourceRow, + sourceRecordId, + detail: "The export row has no usable title." + } + }; + } + const year = parseYear(fields.year); + const abstract = cleanText(fields.abstract); + const doi = normalizeDoi(fields.doi); + const venue = cleanText(fields.venue); + return { + record: { + title, + authors: cleanList(fields.authors), + ...(year ? { year } : {}), + ...(abstract ? { abstract } : {}), + ...(doi ? { doi } : {}), + ...(venue ? { venue } : {}), + keywords: cleanList(fields.keywords), + sourceDatabase: context.database, + sourceRecordId, + sourceRow, + exportArtifactSha256: context.artifactSha256 + } + }; +} + +function resultFromFields( + format: ExportFormat, + fields: readonly RecordFields[], + context: ParserContext, + firstSourceRow = 1 +): ParsedExport { + const records: CanonicalBibliographicRecord[] = []; + const rejections: ExportParseRejection[] = []; + fields.forEach((entry, index) => { + const parsed = buildRecord(entry, context, firstSourceRow + index); + if (parsed.record) records.push(parsed.record); + if (parsed.rejection) rejections.push(parsed.rejection); + }); + return { format, artifactSha256: context.artifactSha256, records, rejections }; +} + +function parseCsvRows(content: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let quoted = false; + for (let index = 0; index < content.length; index += 1) { + const character = content[index]; + if (quoted) { + if (character === "\"") { + if (content[index + 1] === "\"") { + field += "\""; + index += 1; + } else { + quoted = false; + } + } else { + field += character; + } + continue; + } + if (character === "\"") { + quoted = true; + } else if (character === ",") { + row.push(field); + field = ""; + } else if (character === "\n") { + row.push(field.replace(/\r$/, "")); + rows.push(row); + row = []; + field = ""; + } else { + field += character; + } + } + if (quoted) { + throw new Error("CSV ended inside a quoted field."); + } + if (field || row.length > 0) { + row.push(field.replace(/\r$/, "")); + rows.push(row); + } + return rows.filter((entry) => entry.some((cell) => cell.trim())); +} + +const HEADER_ALIASES: Record = { + title: ["title", "article title", "document title", "ti"], + authors: ["authors", "author", "author full names", "au"], + year: ["publication year", "year", "py"], + abstract: ["abstract", "ab"], + doi: ["doi", "digital object identifier"], + venue: ["source title", "publication name", "journal", "venue", "so"], + sourceRecordId: ["accession number", "record id", "eid", "ut", "pmid", "an"], + keywords: ["author keywords", "keywords", "kw"] +}; + +function normalizedHeader(value: string): string { + return value.replace(/^\uFEFF/, "").trim().toLowerCase(); +} + +function headerIndex(headers: readonly string[], field: keyof typeof HEADER_ALIASES): number { + return headers.findIndex((header) => HEADER_ALIASES[field].includes(normalizedHeader(header))); +} + +function cell(row: readonly string[], index: number): string | undefined { + return index >= 0 ? row[index] : undefined; +} + +function fieldsFromRows(rows: readonly string[][]): RecordFields[] { + const headers = rows[0] ?? []; + const indexes = { + title: headerIndex(headers, "title"), + authors: headerIndex(headers, "authors"), + year: headerIndex(headers, "year"), + abstract: headerIndex(headers, "abstract"), + doi: headerIndex(headers, "doi"), + venue: headerIndex(headers, "venue"), + sourceRecordId: headerIndex(headers, "sourceRecordId"), + keywords: headerIndex(headers, "keywords") + }; + return rows.slice(1).map((row) => ({ + title: cell(row, indexes.title), + authors: splitList(cell(row, indexes.authors)), + year: cell(row, indexes.year), + abstract: cell(row, indexes.abstract), + doi: cell(row, indexes.doi), + venue: cell(row, indexes.venue), + sourceRecordId: cell(row, indexes.sourceRecordId), + keywords: splitList(cell(row, indexes.keywords)) + })); +} + +export function parseCsvExport(content: string, context: ParserContext): ParsedExport { + return resultFromFields("csv", fieldsFromRows(parseCsvRows(content)), context, 2); +} + +function parseTaggedRecords( + content: string, + linePattern: RegExp, + recordEnd: (tag: string) => boolean +): Array> { + const records: Array> = []; + let current = new Map(); + let lastTag: string | undefined; + const finish = () => { + if (current.size > 0) records.push(current); + current = new Map(); + lastTag = undefined; + }; + for (const line of content.split(/\r?\n/)) { + const match = line.match(linePattern); + if (match) { + const tag = match[1]; + const value = match[2].trim(); + if (recordEnd(tag)) { + finish(); + continue; + } + current.set(tag, [...(current.get(tag) ?? []), value]); + lastTag = tag; + } else if (/^\s+\S/.test(line) && lastTag) { + const values = current.get(lastTag) ?? []; + const lastIndex = values.length - 1; + values[lastIndex] = `${values[lastIndex]} ${line.trim()}`; + current.set(lastTag, values); + } else if (!line.trim() && current.size > 0) { + finish(); + } + } + finish(); + return records; +} + +function firstTag(record: Map, tags: readonly string[]): string | undefined { + for (const tag of tags) { + const value = record.get(tag)?.[0]; + if (value) return value; + } + return undefined; +} + +function allTags(record: Map, tags: readonly string[]): string[] { + return tags.flatMap((tag) => record.get(tag) ?? []); +} + +export function parseRisExport(content: string, context: ParserContext): ParsedExport { + const records = parseTaggedRecords(content, /^([A-Z0-9]{2}) -\s?(.*)$/, (tag) => tag === "ER"); + const fields = records.map((record): RecordFields => ({ + title: firstTag(record, ["TI", "T1"]), + authors: allTags(record, ["AU", "A1"]), + year: firstTag(record, ["PY", "Y1"]), + abstract: firstTag(record, ["AB", "N2"]), + doi: firstTag(record, ["DO"]), + venue: firstTag(record, ["JO", "JF", "T2"]), + sourceRecordId: firstTag(record, ["AN", "ID"]), + keywords: allTags(record, ["KW"]) + })); + return resultFromFields("ris", fields, context); +} + +export function parseNbibExport(content: string, context: ParserContext): ParsedExport { + const records = parseTaggedRecords(content, /^([A-Z0-9]{2,4})\s*-\s?(.*)$/, () => false); + const fields = records.map((record): RecordFields => ({ + title: firstTag(record, ["TI"]), + authors: allTags(record, ["FAU", "AU"]), + year: firstTag(record, ["DP"]), + abstract: firstTag(record, ["AB"]), + doi: firstTag(record, ["LID", "AID"]), + venue: firstTag(record, ["JT", "TA"]), + sourceRecordId: firstTag(record, ["PMID"]), + keywords: allTags(record, ["OT", "MH"]) + })); + return resultFromFields("nbib", fields, context); +} + +function asObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function crossrefYear(item: Record): number | undefined { + const date = asObject(item.published ?? item.issued); + const parts = asArray(date["date-parts"]); + return parseYear(asArray(parts[0])[0]); +} + +function crossrefAuthor(value: unknown): string | undefined { + const author = asObject(value); + return cleanText([author.given, author.family].map(cleanText).filter(Boolean).join(" ") || author.name); +} + +export function parseJsonExport(content: string, context: ParserContext): ParsedExport { + const parsed = JSON.parse(content) as unknown; + const root = asObject(parsed); + const message = asObject(root.message); + const items = Array.isArray(parsed) ? parsed : asArray(message.items).length > 0 ? asArray(message.items) : asArray(root.records); + const fields = items.map((value): RecordFields => { + const item = asObject(value); + const doi = cleanText(item.DOI ?? item.doi); + return { + title: cleanText(asArray(item.title)[0] ?? item.title), + authors: asArray(item.author ?? item.authors).map(crossrefAuthor).filter((entry): entry is string => Boolean(entry)), + year: crossrefYear(item) ?? parseYear(item.year), + abstract: cleanText(item.abstract), + doi, + venue: cleanText(asArray(item["container-title"])[0] ?? item.venue), + sourceRecordId: cleanText(item.id ?? item.sourceRecordId) ?? normalizeDoi(doi), + keywords: cleanList(asArray(item.subject ?? item.keywords)) + }; + }); + return resultFromFields("json", fields, context); +} + +export function parseSpreadsheetXmlExport(content: string, context: ParserContext): ParsedExport { + const rows = [...content.matchAll(/]*>([\s\S]*?)<\/Row>/gi)].map((rowMatch) => + [...rowMatch[1].matchAll(/]*>([\s\S]*?)<\/Cell>/gi)].map((cellMatch) => { + const data = cellMatch[1].match(/]*>([\s\S]*?)<\/Data>/i)?.[1] ?? ""; + return cleanText(data) ?? ""; + }) + ); + return resultFromFields("spreadsheet_xml", fieldsFromRows(rows), context, 2); +} + +function withEmptyRejection(result: ParsedExport): ParsedExport { + if (result.records.length > 0 || result.rejections.length > 0) { + return result; + } + return { + ...result, + rejections: [{ reason: "empty_export", detail: "The export contains no bibliographic records." }] + }; +} + +export function parseExportArtifact(input: ParseExportArtifactInput): ParsedExport { + if (!EXPORT_FORMATS.includes(input.format as ExportFormat)) { + throw new Error(`Unsupported export format: ${input.format}`); + } + const buffer = typeof input.content === "string" ? Buffer.from(input.content, "utf8") : input.content; + const artifactSha256 = createHash("sha256").update(buffer).digest("hex"); + const context = { database: input.database, artifactSha256 }; + const content = buffer.toString("utf8"); + try { + const parsed = input.format === "csv" + ? parseCsvExport(content, context) + : input.format === "ris" + ? parseRisExport(content, context) + : input.format === "nbib" + ? parseNbibExport(content, context) + : input.format === "json" + ? parseJsonExport(content, context) + : parseSpreadsheetXmlExport(content, context); + return withEmptyRejection(parsed); + } catch (error) { + return { + format: input.format as ExportFormat, + artifactSha256, + records: [], + rejections: [{ + reason: "malformed_export", + detail: `${input.filename}: ${error instanceof Error ? error.message : String(error)}` + }] + }; + } +} diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index 8ad0490..6ddb0de 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -9,3 +9,4 @@ export * from "./workflow-types.js"; export * from "./workflow-state.js"; export * from "./project-store.js"; export * from "./checkpoints.js"; +export * from "./export-parsers.js"; diff --git a/scripts/smoke-export-parsers.mjs b/scripts/smoke-export-parsers.mjs new file mode 100644 index 0000000..5f8bbf2 --- /dev/null +++ b/scripts/smoke-export-parsers.mjs @@ -0,0 +1,72 @@ +import { strict as assert } from "node:assert"; +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const fixtureRoot = join(repoRoot, "packages", "longtable-scholar-research", "fixtures", "exports"); +const research = await import(join(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); +const expected = JSON.parse(await readFile(join(fixtureRoot, "expected-records.json"), "utf8")); + +const cases = [ + ["wos.csv", "csv", "web_of_science"], + ["scopus.ris", "ris", "scopus"], + ["pubmed.nbib", "nbib", "pubmed"], + ["crossref.json", "json", "crossref"], + ["wos.xls", "spreadsheet_xml", "web_of_science"] +]; + +const observed = []; +for (const [filename, format, database] of cases) { + const content = await readFile(join(fixtureRoot, filename)); + const parsed = research.parseExportArtifact({ filename, format, database, content }); + assert.equal(parsed.format, format); + assert.equal(parsed.artifactSha256.length, 64); + assert.equal(parsed.rejections.length, 0, `${filename} should have no parse rejections`); + for (const record of parsed.records) { + assert.equal(record.sourceDatabase, database); + assert.equal(record.exportArtifactSha256, parsed.artifactSha256); + assert(record.sourceRow >= 1); + observed.push({ + sourceRecordId: record.sourceRecordId, + title: record.title, + year: record.year, + ...(record.doi ? { doi: record.doi } : {}), + authorCount: record.authors.length + }); + } +} + +assert.deepEqual(observed, expected); + +const quoted = research.parseCsvExport( + 'Title,Authors,Publication Year,Abstract,Accession Number\r\n"A title, with comma","Doe, Jane; Roe, John",2024,"Line one\nline two",ID-1\r\n', + { database: "test", artifactSha256: "a".repeat(64) } +); +assert.equal(quoted.records[0].title, "A title, with comma"); +assert.equal(quoted.records[0].abstract, "Line one line two"); + +const missingTitle = research.parseCsvExport( + "Title,Authors,Publication Year,Accession Number\n,Kim Mina,2024,ID-MISSING\n", + { database: "test", artifactSha256: "b".repeat(64) } +); +assert.equal(missingTitle.records.length, 0); +assert.equal(missingTitle.rejections[0].reason, "missing_title"); +assert.equal(missingTitle.rejections[0].sourceRecordId, "ID-MISSING"); + +const empty = research.parseExportArtifact({ + filename: "empty.csv", + format: "csv", + database: "test", + content: Buffer.from("Title,Authors\n") +}); +assert.equal(empty.records.length, 0); +assert.equal(empty.rejections[0].reason, "empty_export"); + +assert.throws(() => research.parseExportArtifact({ + filename: "unknown.bin", + format: "binary", + database: "test", + content: Buffer.from([0, 1, 2]) +}), /unsupported export format/i); + +console.log("export parser golden tests passed"); From 7cbc8508e4610a69bb3529298743bfeeb620d654 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:45:27 +0900 Subject: [PATCH 10/25] feat(research): build auditable canonical corpora --- package.json | 3 +- .../longtable-scholar-research/src/corpus.ts | 201 ++++++++++++++++++ .../longtable-scholar-research/src/index.ts | 2 + .../src/invariants.ts | 82 +++++++ scripts/smoke-corpus-pipeline.mjs | 89 ++++++++ 5 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 packages/longtable-scholar-research/src/corpus.ts create mode 100644 packages/longtable-scholar-research/src/invariants.ts create mode 100644 scripts/smoke-corpus-pipeline.mjs diff --git a/package.json b/package.json index 31001ce..7fd834c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "smoke:institutional-workflow": "node scripts/smoke-institutional-workflow.mjs", "smoke:institutional-checkpoints": "node scripts/smoke-institutional-checkpoints.mjs", "smoke:export-parsers": "node scripts/smoke-export-parsers.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run typecheck", + "smoke:corpus-pipeline": "node scripts/smoke-corpus-pipeline.mjs", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, diff --git a/packages/longtable-scholar-research/src/corpus.ts b/packages/longtable-scholar-research/src/corpus.ts new file mode 100644 index 0000000..b38e844 --- /dev/null +++ b/packages/longtable-scholar-research/src/corpus.ts @@ -0,0 +1,201 @@ +import { createHash } from "node:crypto"; +import type { CanonicalBibliographicRecord } from "./export-parsers.js"; + +export interface BibliographicSourceLink { + readonly sourceDatabase: string; + readonly sourceRecordId: string; + readonly sourceRow: number; + readonly exportArtifactSha256: string; +} + +export interface NormalizedBibliographicRecord extends CanonicalBibliographicRecord { + readonly normalizedTitle: string; + readonly recordFingerprint: string; + readonly sourceLink: BibliographicSourceLink; +} + +export interface CanonicalPaper { + readonly paperId: string; + readonly title: string; + readonly normalizedTitle: string; + readonly authors: readonly string[]; + readonly year?: number; + readonly abstract?: string; + readonly doi?: string; + readonly venue?: string; + readonly keywords: readonly string[]; + readonly sourceLinks: readonly BibliographicSourceLink[]; + readonly provenanceBasis: readonly string[]; +} + +export interface DuplicateLink { + readonly paperId: string; + readonly primarySource: BibliographicSourceLink; + readonly duplicateSource: BibliographicSourceLink; + readonly basis: "exact_doi" | "exact_normalized_title_year"; +} + +export interface VersionLink { + readonly leftPaperId: string; + readonly rightPaperId: string; + readonly reason: "same_title_year_different_identifier"; +} + +export interface DeduplicatedBibliographicCorpus { + readonly papers: readonly CanonicalPaper[]; + readonly duplicateLinks: readonly DuplicateLink[]; + readonly versionLinks: readonly VersionLink[]; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function normalizeDoi(value?: string): string | undefined { + if (!value?.trim()) return undefined; + return value + .replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, "") + .replace(/^doi:\s*/i, "") + .trim() + .toLowerCase() || undefined; +} + +export function normalizeBibliographicTitle(value: string): string { + return value + .normalize("NFKC") + .toLocaleLowerCase("en") + .replace(/&/g, " ") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function sourceLink(record: CanonicalBibliographicRecord): BibliographicSourceLink { + return { + sourceDatabase: record.sourceDatabase, + sourceRecordId: record.sourceRecordId, + sourceRow: record.sourceRow, + exportArtifactSha256: record.exportArtifactSha256 + }; +} + +export function normalizeBibliographicRecords( + records: readonly CanonicalBibliographicRecord[] +): NormalizedBibliographicRecord[] { + return records.map((record) => { + const doi = normalizeDoi(record.doi); + const normalizedTitle = normalizeBibliographicTitle(record.title); + const link = sourceLink(record); + return { + ...record, + ...(doi ? { doi } : {}), + normalizedTitle, + sourceLink: link, + recordFingerprint: sha256(JSON.stringify(link)) + }; + }); +} + +function deduplicationKey(record: NormalizedBibliographicRecord): string { + if (record.doi) return `doi:${record.doi}`; + return `title-year:${record.normalizedTitle}:${record.year ?? "unknown"}`; +} + +function compareSourceLinks(left: BibliographicSourceLink, right: BibliographicSourceLink): number { + return left.sourceDatabase.localeCompare(right.sourceDatabase) || + left.sourceRecordId.localeCompare(right.sourceRecordId) || + left.exportArtifactSha256.localeCompare(right.exportArtifactSha256) || + left.sourceRow - right.sourceRow; +} + +function compareRepresentatives(left: NormalizedBibliographicRecord, right: NormalizedBibliographicRecord): number { + return Number(Boolean(right.abstract)) - Number(Boolean(left.abstract)) || + (right.abstract?.length ?? 0) - (left.abstract?.length ?? 0) || + right.authors.length - left.authors.length || + right.title.length - left.title.length || + left.title.localeCompare(right.title) || + compareSourceLinks(left.sourceLink, right.sourceLink); +} + +function uniqueSorted(values: readonly string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))] + .sort((left, right) => left.localeCompare(right)); +} + +function paperFromGroup(key: string, group: readonly NormalizedBibliographicRecord[]): CanonicalPaper { + const sorted = [...group].sort(compareRepresentatives); + const representative = sorted[0]; + const sourceLinks = group.map((record) => record.sourceLink).sort(compareSourceLinks); + const paperId = `paper_${sha256(key).slice(0, 24)}`; + return { + paperId, + title: representative.title, + normalizedTitle: representative.normalizedTitle, + authors: [...representative.authors], + ...(representative.year ? { year: representative.year } : {}), + ...(representative.abstract ? { abstract: representative.abstract } : {}), + ...(representative.doi ? { doi: representative.doi } : {}), + ...(representative.venue ? { venue: representative.venue } : {}), + keywords: uniqueSorted(group.flatMap((record) => record.keywords)), + sourceLinks, + provenanceBasis: [ + representative.doi ? `canonical exact DOI ${representative.doi}` : `canonical exact normalized title/year ${representative.normalizedTitle}/${representative.year ?? "unknown"}`, + ...sourceLinks.map((link) => `${link.sourceDatabase}:${link.sourceRecordId}@${link.exportArtifactSha256}`) + ] + }; +} + +function versionLinks(papers: readonly CanonicalPaper[]): VersionLink[] { + const links: VersionLink[] = []; + for (let leftIndex = 0; leftIndex < papers.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < papers.length; rightIndex += 1) { + const left = papers[leftIndex]; + const right = papers[rightIndex]; + if ( + left.normalizedTitle === right.normalizedTitle && + left.year === right.year && + left.doi && right.doi && left.doi !== right.doi + ) { + links.push({ + leftPaperId: left.paperId, + rightPaperId: right.paperId, + reason: "same_title_year_different_identifier" + }); + } + } + } + return links.sort((left, right) => + left.leftPaperId.localeCompare(right.leftPaperId) || left.rightPaperId.localeCompare(right.rightPaperId) + ); +} + +export function deduplicateBibliographicRecords( + records: readonly NormalizedBibliographicRecord[] +): DeduplicatedBibliographicCorpus { + const groups = new Map(); + for (const record of records) { + const key = deduplicationKey(record); + groups.set(key, [...(groups.get(key) ?? []), record]); + } + + const papers: CanonicalPaper[] = []; + const duplicateLinks: DuplicateLink[] = []; + for (const [key, group] of [...groups.entries()].sort(([left], [right]) => left.localeCompare(right))) { + const paper = paperFromGroup(key, group); + papers.push(paper); + const sources = [...paper.sourceLinks]; + for (const duplicateSource of sources.slice(1)) { + duplicateLinks.push({ + paperId: paper.paperId, + primarySource: sources[0], + duplicateSource, + basis: key.startsWith("doi:") ? "exact_doi" : "exact_normalized_title_year" + }); + } + } + papers.sort((left, right) => left.paperId.localeCompare(right.paperId)); + duplicateLinks.sort((left, right) => + left.paperId.localeCompare(right.paperId) || compareSourceLinks(left.duplicateSource, right.duplicateSource) + ); + return { papers, duplicateLinks, versionLinks: versionLinks(papers) }; +} diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index 6ddb0de..39ced8e 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -10,3 +10,5 @@ export * from "./workflow-state.js"; export * from "./project-store.js"; export * from "./checkpoints.js"; export * from "./export-parsers.js"; +export * from "./corpus.js"; +export * from "./invariants.js"; diff --git a/packages/longtable-scholar-research/src/invariants.ts b/packages/longtable-scholar-research/src/invariants.ts new file mode 100644 index 0000000..eb538a3 --- /dev/null +++ b/packages/longtable-scholar-research/src/invariants.ts @@ -0,0 +1,82 @@ +export interface CorpusCounts { + readonly identified: number; + readonly normalized: number; + readonly parseRejected: number; + readonly unique: number; + readonly duplicateLinks: number; + readonly titleAbstractScreened: number; + readonly fulltextCandidates: number; + readonly titleAbstractExcluded: number; + readonly titleAbstractPending: number; + readonly fulltextSought: number; + readonly fulltextRetrieved: number; + readonly fulltextNotRetrieved: number; + readonly fulltextAssessed: number; + readonly finalIncluded: number; + readonly fulltextExcluded: number; + readonly unresolved: number; +} + +export interface CountEquationAudit { + readonly name: string; + readonly left: number; + readonly right: number; + readonly expression: string; + readonly passed: boolean; +} + +export interface CorpusInvariantAudit { + readonly passed: boolean; + readonly prismaEligible: boolean; + readonly equations: readonly CountEquationAudit[]; +} + +function assertCounts(counts: CorpusCounts): void { + for (const [name, value] of Object.entries(counts)) { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`Corpus count ${name} must be a non-negative integer.`); + } + } +} + +function equation(name: string, left: number, right: number, expression: string): CountEquationAudit { + return { name, left, right, expression, passed: left === right }; +} + +export function verifyCorpusInvariants(counts: CorpusCounts): CorpusInvariantAudit { + assertCounts(counts); + const equations = [ + equation( + "identification", + counts.identified, + counts.normalized + counts.parseRejected, + "identified = normalized + parse_rejected" + ), + equation( + "deduplication", + counts.normalized, + counts.unique + counts.duplicateLinks, + "normalized = unique + duplicate_links" + ), + equation( + "title_abstract_screening", + counts.titleAbstractScreened, + counts.fulltextCandidates + counts.titleAbstractExcluded + counts.titleAbstractPending, + "title_abstract_screened = fulltext_candidates + excluded + pending" + ), + equation( + "fulltext_retrieval", + counts.fulltextSought, + counts.fulltextRetrieved + counts.fulltextNotRetrieved, + "fulltext_sought = retrieved + not_retrieved" + ), + equation( + "fulltext_screening", + counts.fulltextAssessed, + counts.finalIncluded + counts.fulltextExcluded + counts.unresolved, + "fulltext_assessed = final_included + fulltext_excluded + unresolved" + ) + ]; + const passed = equations.every((entry) => entry.passed); + return { passed, prismaEligible: passed, equations }; +} diff --git a/scripts/smoke-corpus-pipeline.mjs b/scripts/smoke-corpus-pipeline.mjs new file mode 100644 index 0000000..d6930b4 --- /dev/null +++ b/scripts/smoke-corpus-pipeline.mjs @@ -0,0 +1,89 @@ +import { strict as assert } from "node:assert"; +import { resolve } from "node:path"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const research = await import(resolve(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); + +function record(overrides) { + return { + title: "Default title", + authors: ["Kim, Mina"], + year: 2024, + abstract: "A sufficiently informative abstract.", + keywords: [], + sourceDatabase: "web_of_science", + sourceRecordId: "WOS:default", + sourceRow: 2, + exportArtifactSha256: "a".repeat(64), + ...overrides + }; +} + +const records = [ + record({ title: "Hackathons & Workplace Capability", doi: "https://doi.org/10.1000/HACK.1", sourceRecordId: "WOS:1" }), + record({ title: "Hackathons and workplace capability", doi: "10.1000/hack.1", sourceDatabase: "scopus", sourceRecordId: "SCOPUS:1", exportArtifactSha256: "b".repeat(64) }), + record({ title: "직무역량 해커톤 연구", year: 2025, doi: undefined, sourceRecordId: "WOS:2" }), + record({ title: "직무역량 해커톤 연구!", year: 2025, doi: undefined, sourceDatabase: "kci", sourceRecordId: "KCI:2", exportArtifactSha256: "c".repeat(64) }), + record({ title: "Design challenge learning", year: 2023, doi: "10.1000/version.a", sourceRecordId: "WOS:3" }), + record({ title: "Design challenge learning", year: 2023, doi: "10.1000/version.b", sourceDatabase: "crossref", sourceRecordId: "10.1000/version.b", exportArtifactSha256: "d".repeat(64) }) +]; + +const normalized = research.normalizeBibliographicRecords(records); +assert.equal(normalized[0].doi, "10.1000/hack.1"); +assert.equal(normalized[0].normalizedTitle, "hackathons workplace capability"); +assert.equal(normalized[2].normalizedTitle, "직무역량 해커톤 연구"); + +const forward = research.deduplicateBibliographicRecords(normalized); +const reverse = research.deduplicateBibliographicRecords(research.normalizeBibliographicRecords([...records].reverse())); +assert.deepEqual(reverse, forward); +assert.equal(forward.papers.length, 4); +assert.equal(forward.duplicateLinks.length, 2); +assert.equal(forward.versionLinks.length, 1); +assert.equal(forward.versionLinks[0].reason, "same_title_year_different_identifier"); + +const doiPaper = forward.papers.find((paper) => paper.doi === "10.1000/hack.1"); +assert(doiPaper); +assert.equal(doiPaper.sourceLinks.length, 2); +assert.equal(doiPaper.paperId.length, 30); +assert.deepEqual(doiPaper.sourceLinks.map((link) => link.sourceDatabase), ["scopus", "web_of_science"]); +assert(forward.duplicateLinks.every((link) => link.basis.length > 0)); +assert(forward.papers.every((paper) => paper.provenanceBasis.length > 0)); + +const validCounts = { + identified: 8, + normalized: 7, + parseRejected: 1, + unique: 4, + duplicateLinks: 3, + titleAbstractScreened: 4, + fulltextCandidates: 2, + titleAbstractExcluded: 1, + titleAbstractPending: 1, + fulltextSought: 2, + fulltextRetrieved: 1, + fulltextNotRetrieved: 1, + fulltextAssessed: 1, + finalIncluded: 1, + fulltextExcluded: 0, + unresolved: 0 +}; + +const validAudit = research.verifyCorpusInvariants(validCounts); +assert.equal(validAudit.passed, true); +assert.equal(validAudit.prismaEligible, true); +assert.equal(validAudit.equations.length, 5); +assert(validAudit.equations.every((equation) => equation.passed)); + +for (const field of ["identified", "normalized", "titleAbstractScreened", "fulltextSought", "fulltextAssessed"]) { + const invalid = research.verifyCorpusInvariants({ ...validCounts, [field]: validCounts[field] + 1 }); + assert.equal(invalid.passed, false, `${field} mutation should fail an invariant`); + assert.equal(invalid.prismaEligible, false); + assert(invalid.equations.some((equation) => !equation.passed)); +} + +assert.throws( + () => research.verifyCorpusInvariants({ ...validCounts, finalIncluded: -1 }), + /non-negative integer/i +); + +console.log("corpus normalization and invariant tests passed"); From b38e1ab00a660efd3e83ac54c75ef821c5ddab26 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:48:41 +0900 Subject: [PATCH 11/25] feat(research): integrate the canonical Research PDF Vault --- package.json | 3 +- .../longtable-scholar-research/src/index.ts | 1 + .../src/pdf-vault.ts | 243 ++++++++++++++++++ scripts/smoke-pdf-vault.mjs | 119 +++++++++ 4 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 packages/longtable-scholar-research/src/pdf-vault.ts create mode 100644 scripts/smoke-pdf-vault.mjs diff --git a/package.json b/package.json index 7fd834c..7ca2a1b 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "smoke:institutional-checkpoints": "node scripts/smoke-institutional-checkpoints.mjs", "smoke:export-parsers": "node scripts/smoke-export-parsers.mjs", "smoke:corpus-pipeline": "node scripts/smoke-corpus-pipeline.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run typecheck", + "smoke:pdf-vault": "node scripts/smoke-pdf-vault.mjs", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index 39ced8e..b6200f5 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -12,3 +12,4 @@ export * from "./checkpoints.js"; export * from "./export-parsers.js"; export * from "./corpus.js"; export * from "./invariants.js"; +export * from "./pdf-vault.js"; diff --git a/packages/longtable-scholar-research/src/pdf-vault.ts b/packages/longtable-scholar-research/src/pdf-vault.ts new file mode 100644 index 0000000..9a1ea93 --- /dev/null +++ b/packages/longtable-scholar-research/src/pdf-vault.ts @@ -0,0 +1,243 @@ +import { constants } from "node:fs"; +import { copyFile, lstat, mkdir, readFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { appendJsonlRecord, readJsonlRecords } from "./project-store.js"; + +export type PdfInspectionFailureReason = + | "placeholder_or_empty" + | "not_a_pdf" + | "incomplete_download" + | "corrupt_pdf_structure" + | "symbolic_link_not_allowed"; + +export type PdfInspection = + | { readonly valid: true; readonly path: string; readonly sha256: string; readonly byteLength: number } + | { readonly valid: false; readonly path: string; readonly reason: PdfInspectionFailureReason; readonly byteLength: number }; + +export type PdfAccessBasis = + | "open_access" + | "institutional_subscription" + | "author_copy" + | "repository" + | "researcher_provided" + | "public_domain"; + +export type PdfAcquisitionMethod = + | "official_api" + | "tdm" + | "institutional_browser_download" + | "open_repository_download" + | "researcher_manual_upload"; + +export interface PdfManifestRecord { + readonly paperId: string; + readonly sha256: string; + readonly canonicalLocalPath: string; + readonly acquisitionMethod: PdfAcquisitionMethod; + readonly accessBasis: PdfAccessBasis; + readonly sourceUrl?: string; + readonly version: string; + readonly projectInclusion: string; + readonly screeningState: string; + readonly acquiredAt: string; +} + +export interface IntakeResearchPdfInput { + readonly projectRoot: string; + readonly vaultRoot: string; + readonly projectManifestPath: string; + readonly paperId: string; + readonly candidatePath: string; + readonly acquisitionMethod: PdfAcquisitionMethod; + readonly accessBasis: PdfAccessBasis; + readonly sourceUrl?: string; + readonly version: string; + readonly projectInclusion: string; + readonly screeningState: string; + readonly acquiredAt?: string; +} + +export interface PdfIntakeResult { + readonly created: boolean; + readonly manifestAppended: boolean; + readonly manifest: PdfManifestRecord; +} + +export interface PdfManifestVerification { + readonly passed: boolean; + readonly issues: readonly string[]; +} + +const SENSITIVE_URL_PARAMETER = /(?:access[_-]?token|refresh[_-]?token|session|cookie|auth|authorization|password|passphrase|credential|api[_-]?key|signature|x-amz-signature|mfa)/i; + +function sha256(buffer: Buffer): string { + return createHash("sha256").update(buffer).digest("hex"); +} + +function isInside(parent: string, child: string): boolean { + const path = relative(resolve(parent), resolve(child)); + return path === "" || (!path.startsWith("..") && !isAbsolute(path)); +} + +function requireText(value: string | undefined, label: string): string { + if (!value?.trim()) throw new Error(`${label} is required for Research PDF Vault intake.`); + return value.trim(); +} + +function assertSafeSourceUrl(value?: string): void { + if (!value) return; + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error("PDF source URL is invalid."); + } + if (parsed.username || parsed.password) { + throw new Error("PDF source URL contains forbidden authentication material."); + } + for (const key of parsed.searchParams.keys()) { + if (SENSITIVE_URL_PARAMETER.test(key)) { + throw new Error("PDF source URL contains forbidden authentication material."); + } + } +} + +export async function inspectPdfCandidate(path: string): Promise { + const resolvedPath = resolve(path); + const metadata = await lstat(resolvedPath); + if (metadata.isSymbolicLink()) { + return { valid: false, path: resolvedPath, reason: "symbolic_link_not_allowed", byteLength: metadata.size }; + } + if (!metadata.isFile() || metadata.size === 0) { + return { valid: false, path: resolvedPath, reason: "placeholder_or_empty", byteLength: metadata.size }; + } + const buffer = await readFile(resolvedPath); + const text = buffer.toString("latin1"); + if (!text.startsWith("%PDF-")) { + return { valid: false, path: resolvedPath, reason: "not_a_pdf", byteLength: buffer.length }; + } + if (!/%%EOF\s*$/.test(text)) { + return { valid: false, path: resolvedPath, reason: "incomplete_download", byteLength: buffer.length }; + } + if (!/\/Type\s*\/Page\b/.test(text) || !/\bxref\b/.test(text) || !/\btrailer\b/.test(text) || !/\bstartxref\b/.test(text)) { + return { valid: false, path: resolvedPath, reason: "corrupt_pdf_structure", byteLength: buffer.length }; + } + return { valid: true, path: resolvedPath, sha256: sha256(buffer), byteLength: buffer.length }; +} + +export function buildPdfManifestRecord( + input: IntakeResearchPdfInput, + inspection: Extract, + canonicalLocalPath: string +): PdfManifestRecord { + assertSafeSourceUrl(input.sourceUrl); + return { + paperId: requireText(input.paperId, "Paper ID"), + sha256: inspection.sha256, + canonicalLocalPath: resolve(canonicalLocalPath), + acquisitionMethod: input.acquisitionMethod, + accessBasis: input.accessBasis, + ...(input.sourceUrl ? { sourceUrl: input.sourceUrl } : {}), + version: requireText(input.version, "PDF version"), + projectInclusion: requireText(input.projectInclusion, "Project inclusion state"), + screeningState: requireText(input.screeningState, "Screening state"), + acquiredAt: input.acquiredAt ?? new Date().toISOString() + }; +} + +function canonicalPdfPath(vaultRoot: string, hash: string): string { + return join(resolve(vaultRoot), hash.slice(0, 2), `${hash}.pdf`); +} + +async function ensureCanonicalPdf( + candidatePath: string, + canonicalPath: string, + expectedSha256: string +): Promise { + await mkdir(dirname(canonicalPath), { recursive: true }); + try { + await copyFile(candidatePath, canonicalPath, constants.COPYFILE_EXCL); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const existing = await inspectPdfCandidate(canonicalPath); + if (!existing.valid || existing.sha256 !== expectedSha256) { + throw new Error(`Research PDF Vault collision or corrupt canonical file at ${canonicalPath}.`); + } + return false; + } +} + +export async function intakeResearchPdf(input: IntakeResearchPdfInput): Promise { + const projectRoot = resolve(input.projectRoot); + const vaultRoot = resolve(input.vaultRoot); + const candidatePath = resolve(input.candidatePath); + const manifestPath = resolve(input.projectManifestPath); + if (isInside(projectRoot, vaultRoot)) { + throw new Error("Research PDF Vault must be outside the research project."); + } + if (isInside(projectRoot, candidatePath)) { + throw new Error("PDF candidate bytes must not be staged inside the research project."); + } + if (!isInside(projectRoot, manifestPath)) { + throw new Error("The PDF manifest must remain inside the research project."); + } + assertSafeSourceUrl(input.sourceUrl); + const inspection = await inspectPdfCandidate(candidatePath); + if (!inspection.valid) { + throw new Error(`PDF intake rejected: ${inspection.reason}.`); + } + const canonicalPath = canonicalPdfPath(vaultRoot, inspection.sha256); + if (!isInside(vaultRoot, canonicalPath)) { + throw new Error("Canonical PDF path escaped the configured Research PDF Vault."); + } + const created = await ensureCanonicalPdf(candidatePath, canonicalPath, inspection.sha256); + const manifest = buildPdfManifestRecord(input, inspection, canonicalPath); + const existingRecords = await readJsonlRecords(manifestPath) as unknown as PdfManifestRecord[]; + const alreadyRecorded = existingRecords.some((record) => + record.paperId === manifest.paperId && + record.sha256 === manifest.sha256 && + record.version === manifest.version + ); + if (!alreadyRecorded) { + await appendJsonlRecord(manifestPath, manifest); + } + return { created, manifestAppended: !alreadyRecorded, manifest }; +} + +export async function verifyPdfManifest( + records: readonly PdfManifestRecord[], + vaultRoot: string +): Promise { + const resolvedVaultRoot = resolve(vaultRoot); + const issues: string[] = []; + const seen = new Set(); + for (const record of records) { + const key = `${record.paperId}:${record.sha256}:${record.version}`; + if (seen.has(key)) { + issues.push(`Duplicate PDF manifest record: ${key}.`); + continue; + } + seen.add(key); + if (!isAbsolute(record.canonicalLocalPath) || !isInside(resolvedVaultRoot, record.canonicalLocalPath)) { + issues.push(`PDF path is outside the configured Research PDF Vault: ${record.paperId}.`); + continue; + } + if (basename(record.canonicalLocalPath) !== `${record.sha256}.pdf`) { + issues.push(`PDF filename does not match its SHA-256: ${record.paperId}.`); + continue; + } + try { + const inspection = await inspectPdfCandidate(record.canonicalLocalPath); + if (!inspection.valid) { + issues.push(`Canonical PDF is invalid (${inspection.reason}): ${record.paperId}.`); + } else if (inspection.sha256 !== record.sha256) { + issues.push(`Canonical PDF SHA-256 mismatch: ${record.paperId}.`); + } + } catch (error) { + issues.push(`Canonical PDF is unavailable for ${record.paperId}: ${error instanceof Error ? error.message : String(error)}.`); + } + } + return { passed: issues.length === 0, issues }; +} diff --git a/scripts/smoke-pdf-vault.mjs b/scripts/smoke-pdf-vault.mjs new file mode 100644 index 0000000..452054a --- /dev/null +++ b/scripts/smoke-pdf-vault.mjs @@ -0,0 +1,119 @@ +import { strict as assert } from "node:assert"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, relative, resolve } from "node:path"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const research = await import(join(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); + +function minimalPdf(text = "LongTable PDF Vault smoke") { + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + `<< /Length ${text.length + 35} >>\nstream\nBT /F1 12 Tf 20 80 Td (${text}) Tj ET\nendstream`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>" + ]; + let body = "%PDF-1.4\n"; + const offsets = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(body)); + body += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + const xrefOffset = Buffer.byteLength(body); + body += `xref\n0 ${objects.length + 1}\n`; + body += "0000000000 65535 f \n"; + body += offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join(""); + body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(body, "ascii"); +} + +async function findPdfFiles(root) { + const found = []; + async function visit(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await visit(path); + if (entry.isFile() && entry.name.toLowerCase().endsWith(".pdf")) found.push(path); + } + } + await visit(root); + return found; +} + +const tempRoot = await mkdtemp(join(tmpdir(), "longtable-pdf-vault-")); +const projectRoot = join(tempRoot, "project"); +const vaultRoot = join(tempRoot, "research-pdf-vault"); +const incomingRoot = join(tempRoot, "incoming"); +await Promise.all([mkdir(projectRoot), mkdir(vaultRoot), mkdir(incomingRoot)]); + +try { + const candidatePath = join(incomingRoot, "paper.pdf"); + await writeFile(candidatePath, minimalPdf()); + const inspection = await research.inspectPdfCandidate(candidatePath); + assert.equal(inspection.valid, true); + assert.equal(inspection.sha256.length, 64); + assert(inspection.byteLength > 300); + + const manifestPath = join(projectRoot, "corpus", "pdf-manifest.jsonl"); + const intakeInput = { + projectRoot, + vaultRoot, + projectManifestPath: manifestPath, + paperId: "paper-1", + candidatePath, + acquisitionMethod: "institutional_browser_download", + accessBasis: "institutional_subscription", + sourceUrl: "https://example.test/article/1", + version: "version_of_record", + projectInclusion: "potential_inclusion", + screeningState: "fulltext_pending", + acquiredAt: "2026-08-06T04:00:00.000Z" + }; + const first = await research.intakeResearchPdf(intakeInput); + assert.equal(first.created, true); + assert.equal(first.manifest.paperId, "paper-1"); + assert.equal(first.manifest.sha256, inspection.sha256); + assert.equal(first.manifest.accessBasis, "institutional_subscription"); + assert(isAbsolute(first.manifest.canonicalLocalPath)); + assert(!relative(vaultRoot, first.manifest.canonicalLocalPath).startsWith("..")); + assert.deepEqual(await readFile(first.manifest.canonicalLocalPath), await readFile(candidatePath)); + + const second = await research.intakeResearchPdf(intakeInput); + assert.equal(second.created, false); + assert.equal(second.manifest.canonicalLocalPath, first.manifest.canonicalLocalPath); + const manifestRecords = await research.readJsonlRecords(manifestPath); + assert.equal(manifestRecords.length, 1); + + const verification = await research.verifyPdfManifest(manifestRecords, vaultRoot); + assert.equal(verification.passed, true); + assert.deepEqual(verification.issues, []); + assert.deepEqual(await findPdfFiles(projectRoot), []); + + const escaped = await research.verifyPdfManifest([{ ...first.manifest, canonicalLocalPath: join(tempRoot, "outside.pdf") }], vaultRoot); + assert.equal(escaped.passed, false); + assert.match(escaped.issues[0], /outside/i); + + await assert.rejects( + research.intakeResearchPdf({ ...intakeInput, vaultRoot: join(projectRoot, "pdfs") }), + /outside the research project/i + ); + await assert.rejects( + research.intakeResearchPdf({ ...intakeInput, sourceUrl: "https://example.test/article?access_token=secret" }), + /authentication material/i + ); + + const placeholder = join(incomingRoot, "placeholder.pdf"); + const incomplete = join(incomingRoot, "incomplete.pdf"); + const corrupt = join(incomingRoot, "corrupt.pdf"); + await writeFile(placeholder, ""); + await writeFile(incomplete, "%PDF-1.4\n1 0 obj\n<< /Type /Page >>\n"); + await writeFile(corrupt, "%PDF-1.4\nxref\ntrailer\n%%EOF\n"); + assert.equal((await research.inspectPdfCandidate(placeholder)).reason, "placeholder_or_empty"); + assert.equal((await research.inspectPdfCandidate(incomplete)).reason, "incomplete_download"); + assert.equal((await research.inspectPdfCandidate(corrupt)).reason, "corrupt_pdf_structure"); +} finally { + await rm(tempRoot, { recursive: true, force: true }); +} + +console.log("Research PDF Vault tests passed"); From 3507c02efe9e8d01f3de22ee988b9ffe317b5195 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:51:26 +0900 Subject: [PATCH 12/25] feat(research): add database and institution adapter contracts --- package.json | 3 +- .../fixtures/institutions/psu-sanitized.json | 33 +++++ .../src/adapters.ts | 79 +++++++++++ .../longtable-scholar-research/src/index.ts | 2 + .../src/institutions.ts | 131 ++++++++++++++++++ .../src/workflow-types.ts | 4 + scripts/smoke-institution-adapters.mjs | 100 +++++++++++++ 7 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 packages/longtable-scholar-research/fixtures/institutions/psu-sanitized.json create mode 100644 packages/longtable-scholar-research/src/adapters.ts create mode 100644 packages/longtable-scholar-research/src/institutions.ts create mode 100644 scripts/smoke-institution-adapters.mjs diff --git a/package.json b/package.json index 7ca2a1b..51196f0 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "smoke:export-parsers": "node scripts/smoke-export-parsers.mjs", "smoke:corpus-pipeline": "node scripts/smoke-corpus-pipeline.mjs", "smoke:pdf-vault": "node scripts/smoke-pdf-vault.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run typecheck", + "smoke:institution-adapters": "node scripts/smoke-institution-adapters.mjs", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, diff --git a/packages/longtable-scholar-research/fixtures/institutions/psu-sanitized.json b/packages/longtable-scholar-research/fixtures/institutions/psu-sanitized.json new file mode 100644 index 0000000..9768f17 --- /dev/null +++ b/packages/longtable-scholar-research/fixtures/institutions/psu-sanitized.json @@ -0,0 +1,33 @@ +{ + "id": "psu-sanitized", + "institutionName": "Pennsylvania State University", + "profileVersion": 1, + "calibrationStatus": "draft_fixture", + "approvedDecisionRecordId": "decision-profile-psu", + "databases": [ + { + "databaseId": "wos", + "accessMode": "browser_sso", + "exportFormats": ["csv", "ris", "spreadsheet_xml"], + "resultCap": 100000, + "recipeId": "wos-accessibility-v1" + }, + { + "databaseId": "scopus", + "accessMode": "browser_sso", + "exportFormats": ["csv", "ris"], + "resultCap": 20000, + "recipeId": "scopus-accessibility-v1" + } + ], + "resolverUrls": ["https://resolver.example.test/openurl"], + "storage": { + "pdfVaultRoot": "/configured/research-pdf-vault", + "exportRoot": "/configured/research-exports" + }, + "checkpointPolicy": "mcp_first", + "permittedActions": ["search", "apply_filters", "export_metadata", "download_permitted_pdf"], + "prohibitedActions": ["persist_credentials", "persist_browser_session", "solve_captcha", "bypass_access_control"], + "calibratedAt": "2026-08-06T00:00:00.000Z", + "lastVerifiedAt": "2026-08-06T00:00:00.000Z" +} diff --git a/packages/longtable-scholar-research/src/adapters.ts b/packages/longtable-scholar-research/src/adapters.ts new file mode 100644 index 0000000..0812a7e --- /dev/null +++ b/packages/longtable-scholar-research/src/adapters.ts @@ -0,0 +1,79 @@ +import { + INSTITUTIONAL_RESEARCH_CAPABILITIES, + type CapabilityResult, + type DatabaseAdapter, + type InstitutionalResearchCapability +} from "./workflow-types.js"; + +export type AdapterCapabilityInput = Readonly>; +export type AdapterCapabilityHandler = ( + input: AdapterCapabilityInput +) => Promise>; + +export type ExecutableDatabaseAdapter = DatabaseAdapter & + Partial>; + +export interface DatabaseAdapterRegistry { + readonly get: (id: string) => ExecutableDatabaseAdapter | undefined; + readonly list: () => readonly ExecutableDatabaseAdapter[]; +} + +export function validateDatabaseAdapter(adapter: ExecutableDatabaseAdapter): string[] { + const issues: string[] = []; + if (!adapter.id?.trim()) issues.push("Database adapter ID is required."); + if (!adapter.version?.trim()) issues.push(`Database adapter ${adapter.id || ""} version is required.`); + const seen = new Set(); + for (const capability of adapter.capabilities ?? []) { + if (!INSTITUTIONAL_RESEARCH_CAPABILITIES.includes(capability)) { + issues.push(`Database adapter ${adapter.id} declares unknown capability ${capability}.`); + continue; + } + if (seen.has(capability)) issues.push(`Database adapter ${adapter.id} declares duplicate capability ${capability}.`); + seen.add(capability); + if (typeof adapter[capability] !== "function") { + issues.push(`Database adapter ${adapter.id} declares ${capability} without an implementation.`); + } + } + return issues; +} + +export async function executeAdapterCapability( + adapter: ExecutableDatabaseAdapter, + capability: InstitutionalResearchCapability, + input: AdapterCapabilityInput +): Promise> { + if (!adapter.capabilities.includes(capability)) { + return { + status: "unsupported", + reason: `Database adapter ${adapter.id} does not declare capability ${capability}.` + }; + } + const handler = adapter[capability]; + if (typeof handler !== "function") { + return { + status: "unsupported", + reason: `Database adapter ${adapter.id} declares ${capability} but has no implementation.` + }; + } + return handler(input); +} + +export function createDatabaseAdapterRegistry( + adapters: readonly ExecutableDatabaseAdapter[] +): DatabaseAdapterRegistry { + const byId = new Map(); + for (const adapter of adapters) { + const issues = validateDatabaseAdapter(adapter); + if (issues.length > 0) { + throw new Error(issues.join(" ")); + } + if (byId.has(adapter.id)) { + throw new Error(`Duplicate database adapter ID: ${adapter.id}.`); + } + byId.set(adapter.id, adapter); + } + return { + get: (id) => byId.get(id), + list: () => [...byId.values()].sort((left, right) => left.id.localeCompare(right.id)) + }; +} diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index b6200f5..810676a 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -13,3 +13,5 @@ export * from "./export-parsers.js"; export * from "./corpus.js"; export * from "./invariants.js"; export * from "./pdf-vault.js"; +export * from "./adapters.js"; +export * from "./institutions.js"; diff --git a/packages/longtable-scholar-research/src/institutions.ts b/packages/longtable-scholar-research/src/institutions.ts new file mode 100644 index 0000000..4c01fa2 --- /dev/null +++ b/packages/longtable-scholar-research/src/institutions.ts @@ -0,0 +1,131 @@ +import type { InstitutionProfile } from "./workflow-types.js"; + +export interface InstitutionCalibrationEvidence { + readonly researcherLoginVerified?: boolean; + readonly resolverDiscoveryVerified?: boolean; + readonly calibrationDatabaseIds: readonly string[]; + readonly fixtureReplayDatabaseIds: readonly string[]; + readonly liveSmokeDatabaseIds: readonly string[]; + readonly decisionRecordIds: readonly string[]; +} + +export interface InstitutionProductionEligibility { + readonly eligible: boolean; + readonly missing: readonly string[]; + readonly coveredDatabaseIds: readonly string[]; +} + +export interface InstitutionCalibrationStep { + readonly key: + | "researcher_login" + | "database_and_resolver_discovery" + | "supervised_dry_run" + | "profile_approval" + | "fixture_replay" + | "live_smoke" + | "production_eligibility"; + readonly complete: boolean; + readonly evidence: readonly string[]; +} + +export interface InstitutionCalibrationChecklist { + readonly institutionProfileId: string; + readonly steps: readonly InstitutionCalibrationStep[]; +} + +function missingDatabaseEvidence( + databaseIds: readonly string[], + evidencedIds: readonly string[], + label: string +): string[] { + const present = new Set(evidencedIds); + return databaseIds + .filter((databaseId) => !present.has(databaseId)) + .map((databaseId) => `${label} evidence is missing for database ${databaseId}.`); +} + +function allCovered(databaseIds: readonly string[], evidencedIds: readonly string[]): boolean { + const present = new Set(evidencedIds); + return databaseIds.every((databaseId) => present.has(databaseId)); +} + +export function assessInstitutionProductionEligibility( + profile: InstitutionProfile, + evidence: InstitutionCalibrationEvidence +): InstitutionProductionEligibility { + const databaseIds = profile.databases.map((database) => database.databaseId); + const missing: string[] = []; + if (evidence.researcherLoginVerified !== true) { + missing.push("Researcher login verification is missing; login and MFA remain researcher-owned."); + } + if (evidence.resolverDiscoveryVerified !== true) { + missing.push("Database and resolver discovery evidence is missing."); + } + missing.push(...missingDatabaseEvidence(databaseIds, evidence.calibrationDatabaseIds, "Supervised calibration dry run")); + missing.push(...missingDatabaseEvidence(databaseIds, evidence.fixtureReplayDatabaseIds, "Local fixture replay")); + missing.push(...missingDatabaseEvidence(databaseIds, evidence.liveSmokeDatabaseIds, "Limited live smoke")); + if (!evidence.decisionRecordIds.includes(profile.approvedDecisionRecordId)) { + missing.push(`Profile approval DecisionRecord ${profile.approvedDecisionRecordId} is missing.`); + } + return { + eligible: missing.length === 0, + missing, + coveredDatabaseIds: databaseIds.filter((databaseId) => + evidence.calibrationDatabaseIds.includes(databaseId) && + evidence.fixtureReplayDatabaseIds.includes(databaseId) && + evidence.liveSmokeDatabaseIds.includes(databaseId) + ).sort() + }; +} + +export function buildInstitutionCalibrationChecklist( + profile: InstitutionProfile, + evidence: InstitutionCalibrationEvidence +): InstitutionCalibrationChecklist { + const databaseIds = profile.databases.map((database) => database.databaseId); + const approvalComplete = evidence.decisionRecordIds.includes(profile.approvedDecisionRecordId); + const dryRunComplete = allCovered(databaseIds, evidence.calibrationDatabaseIds); + const fixtureComplete = allCovered(databaseIds, evidence.fixtureReplayDatabaseIds); + const liveSmokeComplete = allCovered(databaseIds, evidence.liveSmokeDatabaseIds); + const eligibility = assessInstitutionProductionEligibility(profile, evidence); + return { + institutionProfileId: profile.id, + steps: [ + { + key: "researcher_login", + complete: evidence.researcherLoginVerified === true, + evidence: [evidence.researcherLoginVerified ? "Researcher confirmed the authenticated database session without sharing credentials." : "Researcher login confirmation pending."] + }, + { + key: "database_and_resolver_discovery", + complete: evidence.resolverDiscoveryVerified === true, + evidence: [evidence.resolverDiscoveryVerified ? `Resolver discovery recorded for ${profile.resolverUrls?.length ?? 0} configured resolver route(s).` : "Database and resolver discovery pending."] + }, + { + key: "supervised_dry_run", + complete: dryRunComplete, + evidence: [evidence.calibrationDatabaseIds.length > 0 ? `Dry-run databases: ${[...evidence.calibrationDatabaseIds].sort().join(", ")}.` : "Supervised dry run pending."] + }, + { + key: "profile_approval", + complete: approvalComplete, + evidence: [approvalComplete ? `DecisionRecord: ${profile.approvedDecisionRecordId}.` : `DecisionRecord ${profile.approvedDecisionRecordId} pending.`] + }, + { + key: "fixture_replay", + complete: fixtureComplete, + evidence: [evidence.fixtureReplayDatabaseIds.length > 0 ? `Fixture replay databases: ${[...evidence.fixtureReplayDatabaseIds].sort().join(", ")}.` : "Fixture replay pending."] + }, + { + key: "live_smoke", + complete: liveSmokeComplete, + evidence: [evidence.liveSmokeDatabaseIds.length > 0 ? `Live-smoke databases: ${[...evidence.liveSmokeDatabaseIds].sort().join(", ")}.` : "Limited live smoke pending."] + }, + { + key: "production_eligibility", + complete: eligibility.eligible, + evidence: [eligibility.eligible ? "All production gates passed." : eligibility.missing.join(" ")] + } + ] + }; +} diff --git a/packages/longtable-scholar-research/src/workflow-types.ts b/packages/longtable-scholar-research/src/workflow-types.ts index 2f55018..fd8d059 100644 --- a/packages/longtable-scholar-research/src/workflow-types.ts +++ b/packages/longtable-scholar-research/src/workflow-types.ts @@ -137,6 +137,10 @@ export interface InstitutionProfileInput { readonly calibratedAt: string; readonly lastVerifiedAt: string; readonly resolverUrls?: readonly string[]; + readonly profileVersion?: number; + readonly calibrationStatus?: "draft_fixture" | "calibrating" | "approved"; + readonly permittedActions?: readonly string[]; + readonly prohibitedActions?: readonly string[]; } export interface InstitutionProfile extends InstitutionProfileInput {} diff --git a/scripts/smoke-institution-adapters.mjs b/scripts/smoke-institution-adapters.mjs new file mode 100644 index 0000000..eb58334 --- /dev/null +++ b/scripts/smoke-institution-adapters.mjs @@ -0,0 +1,100 @@ +import { strict as assert } from "node:assert"; +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const research = await import(join(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); + +const calls = []; +const fakeAdapter = { + id: "fake-database", + version: "1.0.0", + capabilities: [...research.INSTITUTIONAL_RESEARCH_CAPABILITIES] +}; +for (const capability of research.INSTITUTIONAL_RESEARCH_CAPABILITIES) { + fakeAdapter[capability] = async (input) => { + calls.push(capability); + return { status: "supported", value: { capability, input } }; + }; +} + +assert.deepEqual(research.validateDatabaseAdapter(fakeAdapter), []); +for (const capability of research.INSTITUTIONAL_RESEARCH_CAPABILITIES) { + const result = await research.executeAdapterCapability(fakeAdapter, capability, { runId: "run-1" }); + assert.equal(result.status, "supported"); + assert.equal(result.value.capability, capability); +} +assert.deepEqual(calls, research.INSTITUTIONAL_RESEARCH_CAPABILITIES); + +const limitedAdapter = { + id: "metadata-only", + version: "1.0.0", + capabilities: ["open"], + async open() { return { status: "supported", value: { opened: true } }; } +}; +const unsupported = await research.executeAdapterCapability(limitedAdapter, "exportMetadata", {}); +assert.equal(unsupported.status, "unsupported"); +assert.match(unsupported.reason, /does not declare/i); + +const brokenAdapter = { + id: "broken", + version: "1.0.0", + capabilities: ["open"] +}; +assert.match(research.validateDatabaseAdapter(brokenAdapter)[0], /implementation/i); + +const registry = research.createDatabaseAdapterRegistry([fakeAdapter, limitedAdapter]); +assert.equal(registry.get("fake-database"), fakeAdapter); +assert.deepEqual(registry.list().map((adapter) => adapter.id), ["fake-database", "metadata-only"]); +assert.throws(() => research.createDatabaseAdapterRegistry([fakeAdapter, fakeAdapter]), /duplicate/i); + +const profileInput = JSON.parse(await readFile( + join(repoRoot, "packages", "longtable-scholar-research", "fixtures", "institutions", "psu-sanitized.json"), + "utf8" +)); +const profile = research.createInstitutionProfile(profileInput); +assert.equal(profile.calibrationStatus, "draft_fixture"); +assert.equal(profile.databases.length, 2); +assert.equal(JSON.stringify(profile).includes("token"), false); +assert.equal(JSON.stringify(profile).includes("cookie"), false); + +const initialEligibility = research.assessInstitutionProductionEligibility(profile, { + researcherLoginVerified: false, + resolverDiscoveryVerified: false, + calibrationDatabaseIds: [], + fixtureReplayDatabaseIds: [], + liveSmokeDatabaseIds: [], + decisionRecordIds: [] +}); +assert.equal(initialEligibility.eligible, false); +assert(initialEligibility.missing.some((entry) => entry.includes("calibration"))); +assert(initialEligibility.missing.some((entry) => entry.includes("fixture replay"))); +assert(initialEligibility.missing.some((entry) => entry.includes("live smoke"))); +assert(initialEligibility.missing.some((entry) => entry.includes("DecisionRecord"))); + +const completeEvidence = { + researcherLoginVerified: true, + resolverDiscoveryVerified: true, + calibrationDatabaseIds: ["wos", "scopus"], + fixtureReplayDatabaseIds: ["wos", "scopus"], + liveSmokeDatabaseIds: ["wos", "scopus"], + decisionRecordIds: ["decision-profile-psu"] +}; +const completeEligibility = research.assessInstitutionProductionEligibility(profile, completeEvidence); +assert.equal(completeEligibility.eligible, true); +assert.deepEqual(completeEligibility.missing, []); + +const checklist = research.buildInstitutionCalibrationChecklist(profile, completeEvidence); +assert.deepEqual(checklist.steps.map((step) => step.key), [ + "researcher_login", + "database_and_resolver_discovery", + "supervised_dry_run", + "profile_approval", + "fixture_replay", + "live_smoke", + "production_eligibility" +]); +assert.equal(checklist.steps.at(-1).complete, true); +assert(checklist.steps.every((step) => step.evidence.length > 0)); + +console.log("institution adapter and eligibility tests passed"); From d6a2520d212f183858eddc3db64258bbb08353c1 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:54:54 +0900 Subject: [PATCH 13/25] feat(research): add supervised browser recipe replay --- package.json | 3 +- .../fixtures/browser/signatures.json | 22 ++ .../fixtures/browser/wos-export.html | 11 + .../fixtures/browser/wos-results.html | 12 + .../src/browser-recipes.ts | 323 ++++++++++++++++++ .../longtable-scholar-research/src/index.ts | 1 + scripts/smoke-browser-recipes.mjs | 101 ++++++ 7 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 packages/longtable-scholar-research/fixtures/browser/signatures.json create mode 100644 packages/longtable-scholar-research/fixtures/browser/wos-export.html create mode 100644 packages/longtable-scholar-research/fixtures/browser/wos-results.html create mode 100644 packages/longtable-scholar-research/src/browser-recipes.ts create mode 100644 scripts/smoke-browser-recipes.mjs diff --git a/package.json b/package.json index 51196f0..47d38d8 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "smoke:corpus-pipeline": "node scripts/smoke-corpus-pipeline.mjs", "smoke:pdf-vault": "node scripts/smoke-pdf-vault.mjs", "smoke:institution-adapters": "node scripts/smoke-institution-adapters.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run typecheck", + "smoke:browser-recipes": "node scripts/smoke-browser-recipes.mjs", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run smoke:browser-recipes && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, diff --git a/packages/longtable-scholar-research/fixtures/browser/signatures.json b/packages/longtable-scholar-research/fixtures/browser/signatures.json new file mode 100644 index 0000000..46c0b49 --- /dev/null +++ b/packages/longtable-scholar-research/fixtures/browser/signatures.json @@ -0,0 +1,22 @@ +{ + "wos_results_v1": { + "databaseId": "wos", + "pageKind": "results", + "titlePattern": "Web of Science Results", + "requiredLandmarks": [ + { "role": "heading", "name": "Search Results" }, + { "role": "status", "name": "Result count" }, + { "role": "button", "name": "Export" } + ] + }, + "wos_export_v1": { + "databaseId": "wos", + "pageKind": "export_dialog", + "titlePattern": "Web of Science Export", + "requiredLandmarks": [ + { "role": "dialog", "name": "Export records" }, + { "role": "combobox", "name": "File format" }, + { "role": "button", "name": "Export records" } + ] + } +} diff --git a/packages/longtable-scholar-research/fixtures/browser/wos-export.html b/packages/longtable-scholar-research/fixtures/browser/wos-export.html new file mode 100644 index 0000000..904ec63 --- /dev/null +++ b/packages/longtable-scholar-research/fixtures/browser/wos-export.html @@ -0,0 +1,11 @@ + + +Web of Science Export + +
+ + + +
+ + diff --git a/packages/longtable-scholar-research/fixtures/browser/wos-results.html b/packages/longtable-scholar-research/fixtures/browser/wos-results.html new file mode 100644 index 0000000..3f4647c --- /dev/null +++ b/packages/longtable-scholar-research/fixtures/browser/wos-results.html @@ -0,0 +1,12 @@ + + +Web of Science Results + +
Institution access active
+
+

Search Results

+
1,234 results
+ +
+ + diff --git a/packages/longtable-scholar-research/src/browser-recipes.ts b/packages/longtable-scholar-research/src/browser-recipes.ts new file mode 100644 index 0000000..be0b277 --- /dev/null +++ b/packages/longtable-scholar-research/src/browser-recipes.ts @@ -0,0 +1,323 @@ +import { createHash } from "node:crypto"; +import type { + CapabilityResult, + InstitutionalResearchCapability, + InstitutionalResearchHardStop +} from "./workflow-types.js"; + +export interface BrowserAccessibilityElement { + readonly role: string; + readonly name: string; + readonly value: string; +} + +export interface BrowserPageSnapshot { + readonly url: string; + readonly title: string; + readonly authenticated: boolean; + readonly text: string; + readonly elements: readonly BrowserAccessibilityElement[]; +} + +export interface BrowserSignatureLandmark { + readonly role: string; + readonly name: string; +} + +export interface BrowserSignatureProfile { + readonly databaseId: string; + readonly pageKind: string; + readonly titlePattern: string; + readonly requiredLandmarks: readonly BrowserSignatureLandmark[]; +} + +export interface BrowserPageSignature { + readonly hash: string; + readonly compatible: boolean; + readonly missingLandmarks: readonly BrowserSignatureLandmark[]; +} + +export interface SemanticBrowserSelector { + readonly role: string; + readonly name: string; +} + +export interface CoordinateFallback { + readonly x: number; + readonly y: number; + readonly requiredSignatureHash: string; +} + +export interface BrowserRecipeStep { + readonly capability: InstitutionalResearchCapability; + readonly selector?: SemanticBrowserSelector; + readonly coordinateFallback?: CoordinateFallback; +} + +export interface BrowserRecipe { + readonly id: string; + readonly version: string; + readonly databaseId: string; + readonly pageKind: string; + readonly approvedSignatureHash: string; + readonly productionEligible: boolean; + readonly steps: readonly BrowserRecipeStep[]; +} + +export interface BrowserReplayOptions { + readonly mode: "calibration" | "pilot" | "production"; + readonly resultCap: number; +} + +export interface BrowserReplayAction { + readonly capability: InstitutionalResearchCapability; + readonly selector?: SemanticBrowserSelector; + readonly coordinateFallback?: CoordinateFallback; +} + +export interface BrowserReplayResult { + readonly status: "ready" | "blocked"; + readonly actions: readonly BrowserReplayAction[]; + readonly observations: { readonly resultCount?: number }; + readonly hardStop?: Extract, { status: "hard_stop" }>; +} + +export interface DownloadObservation { + readonly filename: string; + readonly mimeType: string; +} + +export interface DownloadPattern { + readonly filenamePattern: RegExp; + readonly mimeTypes: readonly string[]; +} + +function decodeEntities(value: string): string { + return value + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, "\"") + .replace(/'|'/gi, "'"); +} + +function cleanText(value: string): string { + return decodeEntities(value) + .replace(/]*>[\s\S]*?<\/script>/gi, " ") + .replace(/]*>[\s\S]*?<\/style>/gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function attribute(attributes: string, name: string): string | undefined { + const match = attributes.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i")); + return match ? decodeEntities(match[1]).trim() : undefined; +} + +function implicitRole(tag: string, attributes: string): string | undefined { + const explicit = attribute(attributes, "role"); + if (explicit) return explicit.toLowerCase(); + if (/^h[1-6]$/i.test(tag)) return "heading"; + if (tag.toLowerCase() === "button") return "button"; + if (tag.toLowerCase() === "select") return "combobox"; + if (tag.toLowerCase() === "input") return "textbox"; + return undefined; +} + +function accessibleElements(html: string): BrowserAccessibilityElement[] { + const elements: BrowserAccessibilityElement[] = []; + const tags = ["button", "h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "select", "input"]; + for (const tag of tags) { + const paired = new RegExp(`<${tag}\\b([^>]*)>([\\s\\S]*?)<\\/${tag}>`, "gi"); + for (const match of html.matchAll(paired)) { + const role = implicitRole(tag, match[1]); + if (!role) continue; + const name = attribute(match[1], "aria-label") ?? cleanText(match[2]); + if (!name) continue; + elements.push({ role, name, value: cleanText(match[2]) }); + } + if (tag === "input") { + const single = /]*)\/?\s*>/gi; + for (const match of html.matchAll(single)) { + const role = implicitRole(tag, match[1]); + const name = attribute(match[1], "aria-label") ?? attribute(match[1], "name") ?? attribute(match[1], "type") ?? "input"; + if (role) elements.push({ role, name, value: attribute(match[1], "value") ?? "" }); + } + } + } + return elements.sort((left, right) => + left.role.localeCompare(right.role) || left.name.localeCompare(right.name) || left.value.localeCompare(right.value) + ); +} + +export function buildBrowserPageSnapshot(input: { readonly url: string; readonly html: string }): BrowserPageSnapshot { + const title = cleanText(input.html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""); + const authenticated = /data-authenticated\s*=\s*["']true["']/i.test(input.html) || /aria-label\s*=\s*["']Authenticated institution["']/i.test(input.html); + return { + url: input.url, + title, + authenticated, + text: cleanText(input.html), + elements: accessibleElements(input.html) + }; +} + +function structuralHash(snapshot: BrowserPageSnapshot): string { + const parsedUrl = new URL(snapshot.url); + const canonical = { + origin: parsedUrl.origin, + path: parsedUrl.pathname.replace(/\/+$/, "") || "/", + title: snapshot.title, + authenticated: snapshot.authenticated, + elements: snapshot.elements.map(({ role, name, value }) => ({ role, name, value })) + }; + return createHash("sha256").update(JSON.stringify(canonical)).digest("hex"); +} + +function hasLandmark(snapshot: BrowserPageSnapshot, landmark: BrowserSignatureLandmark): boolean { + return snapshot.elements.some((element) => + element.role.toLowerCase() === landmark.role.toLowerCase() && + element.name.toLowerCase() === landmark.name.toLowerCase() + ); +} + +export function computeBrowserPageSignature( + snapshot: BrowserPageSnapshot, + profile: BrowserSignatureProfile +): BrowserPageSignature { + const missingLandmarks = profile.requiredLandmarks.filter((landmark) => !hasLandmark(snapshot, landmark)); + const titleCompatible = snapshot.title.toLowerCase().includes(profile.titlePattern.toLowerCase()); + return { + hash: structuralHash(snapshot), + compatible: titleCompatible && missingLandmarks.length === 0, + missingLandmarks + }; +} + +export function validateBrowserRecipe(recipe: BrowserRecipe): string[] { + const issues: string[] = []; + if (!recipe.id.trim() || !recipe.version.trim() || !recipe.databaseId.trim()) { + issues.push("Browser recipe requires stable id, version, and database id."); + } + if (!/^[a-f0-9]{64}$/i.test(recipe.approvedSignatureHash)) { + issues.push("Browser recipe requires an approved SHA-256 page signature."); + } + for (const [index, step] of recipe.steps.entries()) { + if (!step.selector && !step.coordinateFallback) { + issues.push(`Browser recipe step ${index + 1} needs a semantic selector or bounded coordinate fallback.`); + } + if (step.coordinateFallback && step.coordinateFallback.requiredSignatureHash !== recipe.approvedSignatureHash) { + issues.push(`Browser recipe step ${index + 1} coordinate fallback signature must match the approved page signature.`); + } + } + return issues; +} + +function hardStop(code: InstitutionalResearchHardStop, reason: string): Extract, { status: "hard_stop" }> { + return { status: "hard_stop", code, reason }; +} + +function detectedBlocker(snapshot: BrowserPageSnapshot): Extract, { status: "hard_stop" }> | undefined { + const text = snapshot.text.toLowerCase(); + if (/session expired|session has expired|timed out/.test(text)) { + return hardStop("SESSION_EXPIRED", "The authenticated database session expired."); + } + if (/multi-factor|verification code|captcha|verify you are human/.test(text)) { + return hardStop("MFA_OR_CAPTCHA_REQUIRED", "Researcher action is required for MFA or CAPTCHA."); + } + if (/updated terms|terms of use|accept.*terms/.test(text)) { + return hardStop("TERMS_OR_ACCESS_UNCLEAR", "Terms or access conditions require researcher review."); + } + if (/type=['\"]password|\bsign in\b|\blog in\b/.test(text)) { + return hardStop("LOGIN_REQUIRED", "The researcher must complete database login."); + } + return undefined; +} + +function elementForSelector( + snapshot: BrowserPageSnapshot, + selector: SemanticBrowserSelector +): BrowserAccessibilityElement | undefined { + return snapshot.elements.find((element) => + element.role.toLowerCase() === selector.role.toLowerCase() && + element.name.toLowerCase() === selector.name.toLowerCase() + ); +} + +function parseResultCount(value: string): number | undefined { + const match = value.match(/[\d,]+/); + return match ? Number(match[0].replace(/,/g, "")) : undefined; +} + +export function replayBrowserRecipe( + recipe: BrowserRecipe, + snapshot: BrowserPageSnapshot, + options: BrowserReplayOptions +): BrowserReplayResult { + const blocker = detectedBlocker(snapshot); + if (blocker) return { status: "blocked", actions: [], observations: {}, hardStop: blocker }; + if (options.mode === "production" && !recipe.productionEligible) { + return { status: "blocked", actions: [], observations: {}, hardStop: hardStop("UI_SIGNATURE_CHANGED", "Exploration recipes are calibration/pilot-only and cannot run in production.") }; + } + const currentHash = structuralHash(snapshot); + if (currentHash !== recipe.approvedSignatureHash) { + return { status: "blocked", actions: [], observations: {}, hardStop: hardStop("UI_SIGNATURE_CHANGED", "The current accessibility page signature differs from the approved browser recipe.") }; + } + const actions: BrowserReplayAction[] = []; + let resultCount: number | undefined; + for (const step of recipe.steps) { + const element = step.selector ? elementForSelector(snapshot, step.selector) : undefined; + if (step.selector && !element && !step.coordinateFallback) { + return { status: "blocked", actions, observations: {}, hardStop: hardStop("UI_SIGNATURE_CHANGED", `Required control ${step.selector.role}:${step.selector.name} was not found.`) }; + } + if (step.coordinateFallback && step.coordinateFallback.requiredSignatureHash !== currentHash) { + return { status: "blocked", actions, observations: {}, hardStop: hardStop("UI_SIGNATURE_CHANGED", "Coordinate fallback is not authorized for the current page signature.") }; + } + actions.push({ + capability: step.capability, + ...(step.selector ? { selector: step.selector } : {}), + ...(step.coordinateFallback ? { coordinateFallback: step.coordinateFallback } : {}) + }); + if (step.capability === "readResultCount" && element) { + resultCount = parseResultCount(element.value); + } + } + if (resultCount !== undefined && resultCount > options.resultCap) { + return { + status: "blocked", + actions, + observations: { resultCount }, + hardStop: hardStop("DATABASE_RESULT_CAP_REACHED", `Observed ${resultCount} results, above the approved cap ${options.resultCap}.`) + }; + } + return { status: "ready", actions, observations: { ...(resultCount !== undefined ? { resultCount } : {}) } }; +} + +function normalizedQuery(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +export function verifySubmittedQuery(expected: string, observed: string): CapabilityResult { + return normalizedQuery(expected) === normalizedQuery(observed) + ? { status: "supported", value: observed } + : hardStop("QUERY_DRIFT_DETECTED", "The submitted database query differs from the frozen protocol query."); +} + +export function verifyExportObservation(expectedCount: number, parsedCount: number): CapabilityResult { + return expectedCount === parsedCount + ? { status: "supported", value: parsedCount } + : hardStop("EXPORT_COUNT_MISMATCH", `Database reported ${expectedCount} records but the export parser recovered ${parsedCount}.`); +} + +export function verifyDownloadPattern( + observed: DownloadObservation, + expected: DownloadPattern +): CapabilityResult { + expected.filenamePattern.lastIndex = 0; + const filenameMatches = expected.filenamePattern.test(observed.filename); + const mimeMatches = expected.mimeTypes.includes(observed.mimeType); + return filenameMatches && mimeMatches + ? { status: "supported", value: observed } + : hardStop("DOWNLOAD_PATTERN_CHANGED", `Observed download ${observed.filename} (${observed.mimeType}) does not match the approved pattern.`); +} diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index 810676a..f8de942 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -15,3 +15,4 @@ export * from "./invariants.js"; export * from "./pdf-vault.js"; export * from "./adapters.js"; export * from "./institutions.js"; +export * from "./browser-recipes.js"; diff --git a/scripts/smoke-browser-recipes.mjs b/scripts/smoke-browser-recipes.mjs new file mode 100644 index 0000000..9f38e3c --- /dev/null +++ b/scripts/smoke-browser-recipes.mjs @@ -0,0 +1,101 @@ +import { strict as assert } from "node:assert"; +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const fixtureRoot = join(repoRoot, "packages", "longtable-scholar-research", "fixtures", "browser"); +const research = await import(join(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); +const profiles = JSON.parse(await readFile(join(fixtureRoot, "signatures.json"), "utf8")); +const resultsHtml = await readFile(join(fixtureRoot, "wos-results.html"), "utf8"); +const exportHtml = await readFile(join(fixtureRoot, "wos-export.html"), "utf8"); + +const resultsSnapshot = research.buildBrowserPageSnapshot({ + url: "https://database.example.test/wos/results", + html: resultsHtml +}); +const exportSnapshot = research.buildBrowserPageSnapshot({ + url: "https://database.example.test/wos/export", + html: exportHtml +}); +assert.equal(resultsSnapshot.authenticated, true); +assert(resultsSnapshot.elements.some((element) => element.role === "button" && element.name === "Export")); +assert(exportSnapshot.elements.some((element) => element.role === "combobox" && element.name === "File format")); + +const resultsSignature = research.computeBrowserPageSignature(resultsSnapshot, profiles.wos_results_v1); +const whitespaceVariant = research.buildBrowserPageSnapshot({ + url: "https://database.example.test/wos/results", + html: resultsHtml.replace(/>\s+ <") +}); +assert.equal(research.computeBrowserPageSignature(whitespaceVariant, profiles.wos_results_v1).hash, resultsSignature.hash); +assert.equal(resultsSignature.compatible, true); + +const recipe = { + id: "wos-results-v1", + version: "1.0.0", + databaseId: "wos", + pageKind: "results", + approvedSignatureHash: resultsSignature.hash, + productionEligible: true, + steps: [ + { capability: "readResultCount", selector: { role: "status", name: "Result count" } }, + { capability: "exportMetadata", selector: { role: "button", name: "Export" } } + ] +}; +assert.deepEqual(research.validateBrowserRecipe(recipe), []); +const replay = research.replayBrowserRecipe(recipe, resultsSnapshot, { mode: "production", resultCap: 2000 }); +assert.equal(replay.status, "ready"); +assert.equal(replay.observations.resultCount, 1234); +assert.deepEqual(replay.actions.map((action) => action.capability), ["readResultCount", "exportMetadata"]); + +const coordinateRecipe = { + ...recipe, + id: "wos-results-coordinate-v1", + steps: [{ + capability: "exportMetadata", + coordinateFallback: { x: 900, y: 140, requiredSignatureHash: resultsSignature.hash } + }] +}; +assert.deepEqual(research.validateBrowserRecipe(coordinateRecipe), []); +assert.match(research.validateBrowserRecipe({ + ...coordinateRecipe, + steps: [{ ...coordinateRecipe.steps[0], coordinateFallback: { x: 900, y: 140, requiredSignatureHash: "wrong" } }] +})[0], /signature/i); + +const changedSnapshot = research.buildBrowserPageSnapshot({ + url: "https://database.example.test/wos/results", + html: resultsHtml.replace('aria-label="Export"', 'aria-label="Download results"') +}); +assert.equal(research.replayBrowserRecipe(recipe, changedSnapshot, { mode: "production", resultCap: 2000 }).hardStop.code, "UI_SIGNATURE_CHANGED"); + +const blockerCases = [ + ["

Sign in

", "LOGIN_REQUIRED"], + ["

Multi-factor authentication

Enter verification code

", "MFA_OR_CAPTCHA_REQUIRED"], + ["

CAPTCHA

Verify you are human

", "MFA_OR_CAPTCHA_REQUIRED"], + ["

Session expired

Please sign in again

", "SESSION_EXPIRED"], + ["

Updated terms of use

", "TERMS_OR_ACCESS_UNCLEAR"] +]; +for (const [html, code] of blockerCases) { + const snapshot = research.buildBrowserPageSnapshot({ url: "https://database.example.test/wos", html }); + assert.equal(research.replayBrowserRecipe(recipe, snapshot, { mode: "production", resultCap: 2000 }).hardStop.code, code); +} + +const capReplay = research.replayBrowserRecipe(recipe, resultsSnapshot, { mode: "production", resultCap: 1000 }); +assert.equal(capReplay.hardStop.code, "DATABASE_RESULT_CAP_REACHED"); +assert.equal(research.verifySubmittedQuery("TS=(hackathon)", "TS=(hackathon)").status, "supported"); +assert.equal(research.verifySubmittedQuery("TS=(hackathon)", "TS=(innovation)").code, "QUERY_DRIFT_DETECTED"); +assert.equal(research.verifyExportObservation(1234, 1234).status, "supported"); +assert.equal(research.verifyExportObservation(1234, 1230).code, "EXPORT_COUNT_MISMATCH"); +assert.equal(research.verifyDownloadPattern( + { filename: "savedrecs.csv", mimeType: "text/csv" }, + { filenamePattern: /^savedrecs.*\.csv$/i, mimeTypes: ["text/csv"] } +).status, "supported"); +assert.equal(research.verifyDownloadPattern( + { filename: "error.html", mimeType: "text/html" }, + { filenamePattern: /^savedrecs.*\.csv$/i, mimeTypes: ["text/csv"] } +).code, "DOWNLOAD_PATTERN_CHANGED"); + +const explorationRecipe = { ...recipe, id: "exploration", productionEligible: false }; +assert.equal(research.replayBrowserRecipe(explorationRecipe, resultsSnapshot, { mode: "production", resultCap: 2000 }).hardStop.code, "UI_SIGNATURE_CHANGED"); +assert.equal(research.replayBrowserRecipe(explorationRecipe, resultsSnapshot, { mode: "pilot", resultCap: 2000 }).status, "ready"); + +console.log("browser recipe replay tests passed"); From f92217b6a4aacc1a44853a1abc276ab3bec3be39 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:57:22 +0900 Subject: [PATCH 14/25] feat(research): add reproducible screening ledgers --- package.json | 3 +- .../longtable-scholar-research/src/index.ts | 1 + .../src/screening.ts | 181 ++++++++++++++++++ scripts/smoke-screening.mjs | 124 ++++++++++++ 4 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 packages/longtable-scholar-research/src/screening.ts create mode 100644 scripts/smoke-screening.mjs diff --git a/package.json b/package.json index 47d38d8..bb84e52 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "smoke:pdf-vault": "node scripts/smoke-pdf-vault.mjs", "smoke:institution-adapters": "node scripts/smoke-institution-adapters.mjs", "smoke:browser-recipes": "node scripts/smoke-browser-recipes.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run smoke:browser-recipes && npm run typecheck", + "smoke:screening": "node scripts/smoke-screening.mjs", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run smoke:browser-recipes && npm run smoke:screening && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index f8de942..bacb92c 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -16,3 +16,4 @@ export * from "./pdf-vault.js"; export * from "./adapters.js"; export * from "./institutions.js"; export * from "./browser-recipes.js"; +export * from "./screening.js"; diff --git a/packages/longtable-scholar-research/src/screening.ts b/packages/longtable-scholar-research/src/screening.ts new file mode 100644 index 0000000..93f2000 --- /dev/null +++ b/packages/longtable-scholar-research/src/screening.ts @@ -0,0 +1,181 @@ +import { createHash } from "node:crypto"; + +export type ScreeningStage = "title_abstract" | "fulltext"; +export type ScreeningDisposition = "include" | "exclude" | "pending" | "full_text_needed_for_screening"; +export type ScreeningActor = "human" | "ai" | "deterministic_rule"; + +export interface ScreeningDecisionInput { + readonly paperId: string; + readonly stage: ScreeningStage; + readonly decision: ScreeningDisposition; + readonly reasonCode?: string; + readonly rationale: string; + readonly ruleVersion: string; + readonly codebookVersion: string; + readonly actor: ScreeningActor; + readonly actorId: string; + readonly modelVersion?: string; + readonly promptVersion?: string; + readonly evidenceArtifactIds: readonly string[]; + readonly decidedAt: string; +} + +export interface ScreeningDecision extends ScreeningDecisionInput { + readonly id: string; +} + +export interface ScreeningCacheKeyInput { + readonly recordOrPdfHash: string; + readonly ruleVersion: string; + readonly codebookVersion: string; + readonly promptVersion: string; + readonly modelVersion: string; +} + +export interface TitleAbstractScreeningCounts { + readonly screened: number; + readonly fulltextCandidates: number; + readonly excluded: number; + readonly pending: number; +} + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value as Record) + .filter(([, nested]) => nested !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, stableValue(nested)])); + } + return value; +} + +function stableJson(value: unknown): string { + return JSON.stringify(stableValue(value)); +} + +function hash(value: unknown): string { + return createHash("sha256").update(stableJson(value)).digest("hex"); +} + +function requireText(value: string | undefined, label: string): string { + if (!value?.trim()) throw new Error(`${label} is required for a screening decision.`); + return value.trim(); +} + +function immutable(value: T): T { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + for (const nested of Object.values(value as Record)) immutable(nested); + Object.freeze(value); + } + return value; +} + +export function classifyTitleAbstractAvailability( + record: { readonly abstract?: string }, + minimumUsableCharacters = 80 +): "screenable" | "full_text_needed_for_screening" { + const normalized = record.abstract?.replace(/\s+/g, " ").trim() ?? ""; + return normalized.length >= minimumUsableCharacters ? "screenable" : "full_text_needed_for_screening"; +} + +export function createScreeningDecision(input: ScreeningDecisionInput): ScreeningDecision { + requireText(input.paperId, "Paper ID"); + requireText(input.rationale, "Screening rationale"); + requireText(input.ruleVersion, "Screening rule version"); + requireText(input.codebookVersion, "Screening codebook version"); + requireText(input.actorId, "Screening actor ID"); + if (!Number.isFinite(Date.parse(input.decidedAt))) { + throw new Error("Screening decision timestamp must be an ISO-compatible date."); + } + if (input.decision === "exclude") requireText(input.reasonCode, "Exclusion reason code"); + if (input.stage === "fulltext" && input.decision === "full_text_needed_for_screening") { + throw new Error("full_text_needed_for_screening is valid only during title/abstract screening."); + } + if (input.actor === "ai" && (!input.modelVersion?.trim() || !input.promptVersion?.trim())) { + throw new Error("AI screening requires model and prompt version provenance."); + } + if (input.evidenceArtifactIds.length === 0) { + throw new Error("A screening decision requires at least one evidence artifact ID."); + } + const canonical = { + ...input, + paperId: input.paperId.trim(), + rationale: input.rationale.trim(), + ruleVersion: input.ruleVersion.trim(), + codebookVersion: input.codebookVersion.trim(), + actorId: input.actorId.trim(), + evidenceArtifactIds: [...new Set(input.evidenceArtifactIds.map((entry) => entry.trim()).filter(Boolean))].sort() + }; + return immutable({ ...canonical, id: `screening_${hash(canonical).slice(0, 24)}` }); +} + +export function appendScreeningDecision( + ledger: readonly ScreeningDecision[], + decision: ScreeningDecision +): ScreeningDecision[] { + const existing = ledger.find((entry) => entry.id === decision.id); + if (!existing) return [...ledger, decision]; + if (stableJson(existing) !== stableJson(decision)) { + throw new Error(`Screening ledger is append-only; decision ${decision.id} cannot be mutated in place.`); + } + return [...ledger]; +} + +function laterDecision(left: ScreeningDecision, right: ScreeningDecision): ScreeningDecision { + const timestampOrder = left.decidedAt.localeCompare(right.decidedAt); + if (timestampOrder !== 0) return timestampOrder > 0 ? left : right; + return left.id.localeCompare(right.id) > 0 ? left : right; +} + +export function reduceLatestScreeningDecisions( + ledger: readonly ScreeningDecision[], + stage: ScreeningStage +): Map { + const byPaper = new Map(); + for (const decision of ledger.filter((entry) => entry.stage === stage)) { + const existing = byPaper.get(decision.paperId); + byPaper.set(decision.paperId, existing ? laterDecision(existing, decision) : decision); + } + return new Map([...byPaper.entries()].sort(([left], [right]) => left.localeCompare(right))); +} + +export function selectFulltextCandidateIds( + paperIds: readonly string[], + ledger: readonly ScreeningDecision[] +): string[] { + const latest = reduceLatestScreeningDecisions(ledger, "title_abstract"); + return [...new Set(paperIds)] + .filter((paperId) => { + const decision = latest.get(paperId)?.decision; + return decision === "include" || decision === "full_text_needed_for_screening"; + }) + .sort(); +} + +export function calculateTitleAbstractScreeningCounts( + paperIds: readonly string[], + ledger: readonly ScreeningDecision[] +): TitleAbstractScreeningCounts { + const uniquePaperIds = [...new Set(paperIds)]; + const latest = reduceLatestScreeningDecisions(ledger, "title_abstract"); + let fulltextCandidates = 0; + let excluded = 0; + let pending = 0; + for (const paperId of uniquePaperIds) { + const decision = latest.get(paperId)?.decision; + if (decision === "include" || decision === "full_text_needed_for_screening") fulltextCandidates += 1; + else if (decision === "exclude") excluded += 1; + else pending += 1; + } + return { screened: uniquePaperIds.length, fulltextCandidates, excluded, pending }; +} + +export function buildScreeningCacheKey(input: ScreeningCacheKeyInput): string { + requireText(input.recordOrPdfHash, "Record or PDF hash"); + requireText(input.ruleVersion, "Screening rule version"); + requireText(input.codebookVersion, "Screening codebook version"); + requireText(input.promptVersion, "Screening prompt version"); + requireText(input.modelVersion, "Screening model version"); + return hash(input); +} diff --git a/scripts/smoke-screening.mjs b/scripts/smoke-screening.mjs new file mode 100644 index 0000000..ff5aafb --- /dev/null +++ b/scripts/smoke-screening.mjs @@ -0,0 +1,124 @@ +import { strict as assert } from "node:assert"; +import { resolve } from "node:path"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const research = await import(resolve(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); + +assert.equal(research.classifyTitleAbstractAvailability({}), "full_text_needed_for_screening"); +assert.equal(research.classifyTitleAbstractAvailability({ abstract: "Short abstract." }), "full_text_needed_for_screening"); +assert.equal(research.classifyTitleAbstractAvailability({ abstract: "This abstract contains enough detail to identify the population, intervention, context, and workplace competency outcome for an initial screening decision." }), "screenable"); + +const base = { + stage: "title_abstract", + ruleVersion: "eligibility-v1", + codebookVersion: "codebook-v1", + actor: "human", + actorId: "reviewer-hy", + evidenceArtifactIds: ["export-record-1"] +}; + +const decisions = [ + research.createScreeningDecision({ + ...base, + paperId: "paper-1", + decision: "include", + rationale: "Hackathon protocol and workplace competency outcome are explicit.", + decidedAt: "2026-08-06T05:00:00.000Z" + }), + research.createScreeningDecision({ + ...base, + paperId: "paper-2", + decision: "exclude", + reasonCode: "wrong_context", + rationale: "The event is a marketing competition without a learning or work context.", + decidedAt: "2026-08-06T05:01:00.000Z" + }), + research.createScreeningDecision({ + ...base, + paperId: "paper-3", + decision: "pending", + rationale: "Population is unclear.", + decidedAt: "2026-08-06T05:02:00.000Z" + }), + research.createScreeningDecision({ + ...base, + paperId: "paper-4", + decision: "full_text_needed_for_screening", + rationale: "No abstract is available.", + decidedAt: "2026-08-06T05:03:00.000Z" + }) +]; + +assert(decisions.every((decision) => decision.id.startsWith("screening_"))); +assert(decisions.every((decision) => decision.ruleVersion === "eligibility-v1")); +assert(decisions.every((decision) => decision.evidenceArtifactIds.length === 1)); +assert.throws(() => research.createScreeningDecision({ + ...base, + paperId: "paper-x", + decision: "exclude", + rationale: "Excluded without a standardized reason.", + decidedAt: "2026-08-06T05:04:00.000Z" +}), /reason code/i); + +let ledger = []; +for (const decision of decisions) ledger = research.appendScreeningDecision(ledger, decision); +assert.equal(ledger.length, 4); +ledger = research.appendScreeningDecision(ledger, decisions[0]); +assert.equal(ledger.length, 4); +assert.throws(() => research.appendScreeningDecision(ledger, { ...decisions[0], rationale: "Mutated in place" }), /append-only/i); + +const laterPaperThree = research.createScreeningDecision({ + ...base, + paperId: "paper-3", + decision: "include", + rationale: "A later verified metadata source clarified the population.", + evidenceArtifactIds: ["export-record-1", "crossref-record-3"], + decidedAt: "2026-08-06T05:10:00.000Z" +}); +ledger = research.appendScreeningDecision(ledger, laterPaperThree); +const latest = research.reduceLatestScreeningDecisions(ledger, "title_abstract"); +assert.equal(latest.get("paper-3").decision, "include"); + +assert.deepEqual(research.selectFulltextCandidateIds(["paper-1", "paper-2", "paper-3", "paper-4"], ledger), ["paper-1", "paper-3", "paper-4"]); +assert.deepEqual(research.calculateTitleAbstractScreeningCounts(["paper-1", "paper-2", "paper-3", "paper-4", "paper-5"], ledger), { + screened: 5, + fulltextCandidates: 3, + excluded: 1, + pending: 1 +}); + +const aiDecision = research.createScreeningDecision({ + ...base, + paperId: "paper-6", + actor: "ai", + actorId: "screening-model", + modelVersion: "model-2026-08", + promptVersion: "screening-prompt-v1", + decision: "pending", + rationale: "The construct mapping is ambiguous.", + decidedAt: "2026-08-06T05:11:00.000Z" +}); +assert(aiDecision.modelVersion); +assert.throws(() => research.createScreeningDecision({ + ...base, + paperId: "paper-7", + actor: "ai", + actorId: "screening-model", + decision: "pending", + rationale: "Missing model provenance.", + decidedAt: "2026-08-06T05:12:00.000Z" +}), /model and prompt version/i); + +const cacheInput = { + recordOrPdfHash: "a".repeat(64), + ruleVersion: "eligibility-v1", + codebookVersion: "codebook-v1", + promptVersion: "screening-prompt-v1", + modelVersion: "model-2026-08" +}; +const cacheKey = research.buildScreeningCacheKey(cacheInput); +assert.equal(cacheKey.length, 64); +assert.equal(research.buildScreeningCacheKey(cacheInput), cacheKey); +assert.notEqual(research.buildScreeningCacheKey({ ...cacheInput, ruleVersion: "eligibility-v2" }), cacheKey); + +console.log("screening ledger tests passed"); From e9f6179833d4dd31615a42c87d274dceafe5d5f4 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:00:38 +0900 Subject: [PATCH 15/25] feat(research): hard-stop ambiguous screening decisions --- .../src/checkpoints.ts | 29 ++++ .../src/screening.ts | 142 ++++++++++++++++++ .../src/workflow-state.ts | 19 +++ scripts/smoke-screening.mjs | 111 ++++++++++++++ 4 files changed, 301 insertions(+) diff --git a/packages/longtable-scholar-research/src/checkpoints.ts b/packages/longtable-scholar-research/src/checkpoints.ts index e279eb9..323e167 100644 --- a/packages/longtable-scholar-research/src/checkpoints.ts +++ b/packages/longtable-scholar-research/src/checkpoints.ts @@ -83,6 +83,12 @@ export interface AnalysisMethodCheckpointInput extends CheckpointIdentity { readonly rationale: string; } +export interface ScreeningRuleAmbiguityCheckpointInput extends CheckpointIdentity { + readonly ruleVersion: string; + readonly ambiguity: string; + readonly affectedPaperIds: readonly string[]; +} + interface RequiredCheckpointInput extends CheckpointIdentity { readonly stage: string; readonly code: string; @@ -374,3 +380,26 @@ export function buildAnalysisMethodCheckpoint(input: AnalysisMethodCheckpointInp rationale: ["The renderer cannot claim an unapproved analysis."] }); } + +export function buildScreeningRuleAmbiguityCheckpoint( + input: ScreeningRuleAmbiguityCheckpointInput +): QuestionRecord { + return buildRequiredCheckpoint({ + ...input, + stage: "TITLE_ABSTRACT_SCREENING", + code: "SCREENING_RULE_AMBIGUOUS", + title: "Screening-rule ambiguity", + question: `Rule ${input.ruleVersion} is ambiguous: ${input.ambiguity}. Affected records: ${[...input.affectedPaperIds].sort().join(", ")}.`, + type: "single_choice", + options: [ + { value: "clarify_rule", label: "Clarify rule", description: "Create a versioned rule clarification before screening affected records.", recommended: true }, + { value: "open_record_review", label: "Review affected records", description: "Inspect the evidence without silently changing the rule." }, + { value: "suspend", label: "Suspend", description: "Keep affected records pending." } + ], + scope: "construct", + family: "coding", + epistemicBasis: "mixed", + displayReason: "An ambiguous inclusion or exclusion rule can change the final corpus.", + rationale: ["Affected records remain pending until the researcher records a decision."] + }); +} diff --git a/packages/longtable-scholar-research/src/screening.ts b/packages/longtable-scholar-research/src/screening.ts index 93f2000..d37b0f4 100644 --- a/packages/longtable-scholar-research/src/screening.ts +++ b/packages/longtable-scholar-research/src/screening.ts @@ -1,4 +1,6 @@ import { createHash } from "node:crypto"; +import type { CapabilityResult, InstitutionalResearchHardStop } from "./workflow-types.js"; +import { buildScreeningConflictCheckpoint } from "./checkpoints.js"; export type ScreeningStage = "title_abstract" | "fulltext"; export type ScreeningDisposition = "include" | "exclude" | "pending" | "full_text_needed_for_screening"; @@ -18,6 +20,7 @@ export interface ScreeningDecisionInput { readonly promptVersion?: string; readonly evidenceArtifactIds: readonly string[]; readonly decidedAt: string; + readonly adjudicatesDecisionIds?: readonly string[]; } export interface ScreeningDecision extends ScreeningDecisionInput { @@ -39,6 +42,40 @@ export interface TitleAbstractScreeningCounts { readonly pending: number; } +export interface HumanAiScreeningConflict { + readonly id: string; + readonly paperId: string; + readonly stage: ScreeningStage; + readonly humanDecisionId: string; + readonly aiDecisionId: string; + readonly decisionIds: readonly string[]; + readonly humanDisposition: ScreeningDisposition; + readonly aiDisposition: ScreeningDisposition; +} + +export interface ScreeningConflictAuditRecord { + readonly id: string; + readonly createdAt: string; + readonly code: "HUMAN_AI_SCREENING_CONFLICT"; + readonly paperIds: readonly string[]; + readonly conflictIds: readonly string[]; + readonly decisionIds: readonly string[]; + readonly basis: string; +} + +export interface BundleScreeningConflictsInput { + readonly runId: string; + readonly protocolRevisionId: string; + readonly conflicts: readonly HumanAiScreeningConflict[]; + readonly createdAt?: string; +} + +export interface FulltextMissingThresholdInput { + readonly sought: number; + readonly retrieved: number; + readonly maximumMissingRate: number; +} + function stableValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(stableValue); if (value && typeof value === "object") { @@ -179,3 +216,108 @@ export function buildScreeningCacheKey(input: ScreeningCacheKeyInput): string { requireText(input.modelVersion, "Screening model version"); return hash(input); } + +function latestByActor( + ledger: readonly ScreeningDecision[], + actor: "human" | "ai" +): Map { + const latest = new Map(); + for (const decision of ledger.filter((entry) => entry.actor === actor)) { + const key = `${decision.paperId}:${decision.stage}`; + const existing = latest.get(key); + latest.set(key, existing ? laterDecision(existing, decision) : decision); + } + return latest; +} + +export function detectHumanAiScreeningConflicts( + ledger: readonly ScreeningDecision[] +): HumanAiScreeningConflict[] { + const humans = latestByActor(ledger, "human"); + const ais = latestByActor(ledger, "ai"); + const conflicts: HumanAiScreeningConflict[] = []; + for (const [key, human] of humans) { + const ai = ais.get(key); + if (!ai || ai.decision === human.decision) continue; + const decisionIds = [human.id, ai.id].sort(); + conflicts.push({ + id: `screening_conflict_${hash({ key, decisionIds }).slice(0, 20)}`, + paperId: human.paperId, + stage: human.stage, + humanDecisionId: human.id, + aiDecisionId: ai.id, + decisionIds, + humanDisposition: human.decision, + aiDisposition: ai.decision + }); + } + return conflicts.sort((left, right) => left.paperId.localeCompare(right.paperId) || left.stage.localeCompare(right.stage)); +} + +export function bundleScreeningConflicts(input: BundleScreeningConflictsInput): { + readonly auditRecord: ScreeningConflictAuditRecord; + readonly question: ReturnType; +} { + if (input.conflicts.length === 0) throw new Error("At least one human–AI screening conflict is required."); + const createdAt = input.createdAt ?? new Date().toISOString(); + const paperIds = [...new Set(input.conflicts.map((conflict) => conflict.paperId))].sort(); + const conflictIds = input.conflicts.map((conflict) => conflict.id).sort(); + const decisionIds = [...new Set(input.conflicts.flatMap((conflict) => conflict.decisionIds))].sort(); + const auditRecord: ScreeningConflictAuditRecord = { + id: `screening_conflict_bundle_${hash({ input: input.runId, paperIds, conflictIds }).slice(0, 20)}`, + createdAt, + code: "HUMAN_AI_SCREENING_CONFLICT", + paperIds, + conflictIds, + decisionIds, + basis: "Latest human and AI screening dispositions disagree for the affected paper/stage pairs." + }; + return { + auditRecord, + question: buildScreeningConflictCheckpoint({ + runId: input.runId, + protocolRevisionId: input.protocolRevisionId, + paperIds, + conflict: `${input.conflicts.length} human–AI screening conflict(s) require adjudication`, + createdAt + }) + }; +} + +export function applyScreeningAdjudication( + ledger: readonly ScreeningDecision[], + input: ScreeningDecisionInput & { readonly adjudicatesDecisionIds: readonly string[] } +): ScreeningDecision[] { + if (input.actor !== "human") throw new Error("Screening adjudication must be recorded by a human actor."); + if (input.adjudicatesDecisionIds.length < 2) throw new Error("Screening adjudication must reference the conflicting decision IDs."); + const existingIds = new Set(ledger.map((decision) => decision.id)); + const missingIds = input.adjudicatesDecisionIds.filter((id) => !existingIds.has(id)); + if (missingIds.length > 0) throw new Error(`Screening adjudication references unknown decisions: ${missingIds.join(", ")}.`); + return appendScreeningDecision(ledger, createScreeningDecision({ + ...input, + adjudicatesDecisionIds: [...new Set(input.adjudicatesDecisionIds)].sort() + })); +} + +function thresholdHardStop(code: InstitutionalResearchHardStop, reason: string): Extract, { status: "hard_stop" }> { + return { status: "hard_stop", code, reason }; +} + +export function evaluateFulltextMissingThreshold( + input: FulltextMissingThresholdInput +): CapabilityResult<{ readonly missing: number; readonly missingRate: number }> { + if (!Number.isInteger(input.sought) || !Number.isInteger(input.retrieved) || input.sought < 0 || input.retrieved < 0 || input.retrieved > input.sought) { + throw new Error("Full-text sought and retrieved counts must be valid non-negative integers."); + } + if (input.maximumMissingRate < 0 || input.maximumMissingRate > 1) { + throw new Error("Maximum full-text missing rate must be between 0 and 1."); + } + const missing = input.sought - input.retrieved; + const missingRate = input.sought === 0 ? 0 : missing / input.sought; + return missingRate <= input.maximumMissingRate + ? { status: "supported", value: { missing, missingRate } } + : thresholdHardStop( + "FULLTEXT_MISSING_THRESHOLD_EXCEEDED", + `Full-text missing rate ${(missingRate * 100).toFixed(1)}% exceeds the approved ${(input.maximumMissingRate * 100).toFixed(1)}% threshold.` + ); +} diff --git a/packages/longtable-scholar-research/src/workflow-state.ts b/packages/longtable-scholar-research/src/workflow-state.ts index 74df4a5..109122f 100644 --- a/packages/longtable-scholar-research/src/workflow-state.ts +++ b/packages/longtable-scholar-research/src/workflow-state.ts @@ -6,6 +6,7 @@ import { type ResearchRun, type StageReceipt } from "./workflow-types.js"; +import type { QuestionRecord } from "@longtable/core"; export interface CreateResearchRunInput { readonly id: string; @@ -170,3 +171,21 @@ export function resumeResearchRun( resumedByDecisionRecordId: decision.id }); } + +export function blockResearchRunForQuestion( + run: ResearchRun, + code: InstitutionalResearchHardStop, + question: QuestionRecord, + safeCursor: string +): ResearchRun { + if (question.status !== "pending" || question.hardStop !== true || !question.prompt.checkpointKey) { + throw new Error("A research run can be blocked only by a pending hard-stop QuestionRecord with a checkpoint key."); + } + return blockResearchRun(run, { + code, + questionRecordId: question.id, + checkpointKey: question.prompt.checkpointKey, + safeCursor, + blockedAt: question.createdAt + }); +} diff --git a/scripts/smoke-screening.mjs b/scripts/smoke-screening.mjs index ff5aafb..6da3e82 100644 --- a/scripts/smoke-screening.mjs +++ b/scripts/smoke-screening.mjs @@ -121,4 +121,115 @@ assert.equal(cacheKey.length, 64); assert.equal(research.buildScreeningCacheKey(cacheInput), cacheKey); assert.notEqual(research.buildScreeningCacheKey({ ...cacheInput, ruleVersion: "eligibility-v2" }), cacheKey); +const conflictDecisions = [ + research.createScreeningDecision({ + ...base, + paperId: "paper-8", + actor: "human", + actorId: "reviewer-hy", + decision: "include", + rationale: "The paper meets the protocol criteria.", + decidedAt: "2026-08-06T05:20:00.000Z" + }), + research.createScreeningDecision({ + ...base, + paperId: "paper-8", + actor: "ai", + actorId: "screening-model", + modelVersion: "model-2026-08", + promptVersion: "screening-prompt-v1", + decision: "exclude", + reasonCode: "wrong_intervention", + rationale: "The model interpreted the intervention as an ordinary course.", + decidedAt: "2026-08-06T05:21:00.000Z" + }), + research.createScreeningDecision({ + ...base, + paperId: "paper-9", + actor: "human", + actorId: "reviewer-hy", + decision: "exclude", + reasonCode: "wrong_outcome", + rationale: "No workplace competency outcome is measured.", + decidedAt: "2026-08-06T05:22:00.000Z" + }), + research.createScreeningDecision({ + ...base, + paperId: "paper-9", + actor: "ai", + actorId: "screening-model", + modelVersion: "model-2026-08", + promptVersion: "screening-prompt-v1", + decision: "include", + rationale: "The model inferred a competency outcome from broad learning language.", + decidedAt: "2026-08-06T05:23:00.000Z" + }) +]; +const conflicts = research.detectHumanAiScreeningConflicts(conflictDecisions); +assert.deepEqual(conflicts.map((conflict) => conflict.paperId), ["paper-8", "paper-9"]); +assert(conflicts.every((conflict) => conflict.humanDecisionId && conflict.aiDecisionId)); + +const conflictBundle = research.bundleScreeningConflicts({ + runId: "run-screening", + protocolRevisionId: "protocol-1", + conflicts, + createdAt: "2026-08-06T05:24:00.000Z" +}); +assert.deepEqual(conflictBundle.auditRecord.paperIds, ["paper-8", "paper-9"]); +assert.equal(conflictBundle.question.prompt.checkpointKey, "institutional:run-screening:protocol-1:FULLTEXT_SCREENING:HUMAN_AI_SCREENING_CONFLICT"); +assert.equal(conflictBundle.question.hardStop, true); + +const screeningRun = research.createResearchRun({ + id: "run-screening", + createdAt: "2026-08-06T05:19:00.000Z", + stage: "FULLTEXT_SCREENING", + protocolRevisionId: "protocol-1", + institutionProfileId: "psu-sanitized" +}); +const blockedRun = research.blockResearchRunForQuestion( + screeningRun, + "HUMAN_AI_SCREENING_CONFLICT", + conflictBundle.question, + "screening:paper-7" +); +assert.equal(blockedRun.status, "blocked"); +assert.throws(() => research.resumeResearchRun(blockedRun, []), /DecisionRecord/i); +const resumedRun = research.resumeResearchRun(blockedRun, [{ + id: "decision-screening-conflict", + checkpointKey: conflictBundle.question.prompt.checkpointKey, + questionRecordId: conflictBundle.question.id +}]); +assert.equal(resumedRun.status, "running"); + +const adjudicatedLedger = research.applyScreeningAdjudication(conflictDecisions, { + ...base, + paperId: "paper-8", + actor: "human", + actorId: "adjudicator-hy", + decision: "include", + rationale: "Full-text review confirms the intervention is an intensive challenge protocol.", + evidenceArtifactIds: ["pdf-paper-8"], + decidedAt: "2026-08-06T05:25:00.000Z", + adjudicatesDecisionIds: conflicts[0].decisionIds +}); +assert.equal(adjudicatedLedger.length, conflictDecisions.length + 1); +assert.equal(conflictDecisions.length, 4); +assert.equal(research.reduceLatestScreeningDecisions(adjudicatedLedger, "title_abstract").get("paper-8").actorId, "adjudicator-hy"); + +assert.equal(research.evaluateFulltextMissingThreshold({ sought: 10, retrieved: 9, maximumMissingRate: 0.1 }).status, "supported"); +const missingThreshold = research.evaluateFulltextMissingThreshold({ sought: 10, retrieved: 8, maximumMissingRate: 0.1 }); +assert.equal(missingThreshold.status, "hard_stop"); +assert.equal(missingThreshold.code, "FULLTEXT_MISSING_THRESHOLD_EXCEEDED"); + +const ambiguity = research.buildScreeningRuleAmbiguityCheckpoint({ + runId: "run-screening", + protocolRevisionId: "protocol-1", + ruleVersion: "eligibility-v1", + ambiguity: "Hackathon-like challenge weeks are not explicitly covered.", + affectedPaperIds: ["paper-10", "paper-11"], + createdAt: "2026-08-06T05:26:00.000Z" +}); +assert.equal(ambiguity.prompt.checkpointKey, "institutional:run-screening:protocol-1:TITLE_ABSTRACT_SCREENING:SCREENING_RULE_AMBIGUOUS"); +assert.equal(ambiguity.hardStopScope, "construct"); + console.log("screening ledger tests passed"); From a046af85940b0366161f9fcd126f3ae41b235c0c Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:03:03 +0900 Subject: [PATCH 16/25] feat(research): render audited review reports --- package.json | 3 +- .../longtable-scholar-research/src/index.ts | 2 + .../longtable-scholar-research/src/prisma.ts | 70 +++++++ .../src/reporting.ts | 176 ++++++++++++++++++ scripts/smoke-research-reporting.mjs | 96 ++++++++++ 5 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 packages/longtable-scholar-research/src/prisma.ts create mode 100644 packages/longtable-scholar-research/src/reporting.ts create mode 100644 scripts/smoke-research-reporting.mjs diff --git a/package.json b/package.json index bb84e52..f7290c2 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "smoke:institution-adapters": "node scripts/smoke-institution-adapters.mjs", "smoke:browser-recipes": "node scripts/smoke-browser-recipes.mjs", "smoke:screening": "node scripts/smoke-screening.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run smoke:browser-recipes && npm run smoke:screening && npm run typecheck", + "smoke:research-reporting": "node scripts/smoke-research-reporting.mjs", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run smoke:browser-recipes && npm run smoke:screening && npm run smoke:research-reporting && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index bacb92c..58a4eb4 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -17,3 +17,5 @@ export * from "./adapters.js"; export * from "./institutions.js"; export * from "./browser-recipes.js"; export * from "./screening.js"; +export * from "./prisma.js"; +export * from "./reporting.js"; diff --git a/packages/longtable-scholar-research/src/prisma.ts b/packages/longtable-scholar-research/src/prisma.ts new file mode 100644 index 0000000..4956258 --- /dev/null +++ b/packages/longtable-scholar-research/src/prisma.ts @@ -0,0 +1,70 @@ +import type { CorpusCounts } from "./invariants.js"; +import { verifyCorpusInvariants } from "./invariants.js"; + +function requireEligibleCounts(counts: CorpusCounts): void { + if (!verifyCorpusInvariants(counts).prismaEligible) { + throw new Error("Cannot render PRISMA artifacts because corpus count invariants failed."); + } +} + +const COUNT_ROWS: readonly [string, keyof CorpusCounts][] = [ + ["Identified records", "identified"], + ["Parse-rejected records", "parseRejected"], + ["Unique records", "unique"], + ["Duplicate links", "duplicateLinks"], + ["Title/abstract records screened", "titleAbstractScreened"], + ["Title/abstract records excluded", "titleAbstractExcluded"], + ["Reports sought for retrieval", "fulltextSought"], + ["Reports not retrieved", "fulltextNotRetrieved"], + ["Reports assessed for eligibility", "fulltextAssessed"], + ["Full-text reports excluded", "fulltextExcluded"], + ["Studies included in review", "finalIncluded"], + ["Unresolved records", "unresolved"] +]; + +export function renderPrismaCountTable(counts: CorpusCounts): string { + requireEligibleCounts(counts); + return [ + "| Review flow item | Count |", + "|---|---:|", + ...COUNT_ROWS.map(([label, key]) => `| ${label} | ${counts[key]} |`) + ].join("\n"); +} + +function escapeXml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} + +export function renderPrismaFlowSvg(counts: CorpusCounts): string { + requireEligibleCounts(counts); + const nodes = [ + ["Records identified", counts.identified], + ["Unique records screened", counts.titleAbstractScreened], + ["Reports sought for retrieval", counts.fulltextSought], + ["Reports assessed for eligibility", counts.fulltextAssessed], + ["Studies included in review", counts.finalIncluded] + ] as const; + const nodeHeight = 64; + const gap = 36; + const width = 640; + const height = 40 + nodes.length * nodeHeight + (nodes.length - 1) * gap + 40; + const elements: string[] = []; + nodes.forEach(([label, count], index) => { + const y = 40 + index * (nodeHeight + gap); + if (index > 0) { + elements.push(``); + } + elements.push(``); + elements.push(`${escapeXml(label)}`); + elements.push(`n = ${count}`); + }); + return [ + ``, + "Review study flow", + "Deterministic flow generated from the audited corpus counts.", + "", + ...elements, + "" + ].join("\n"); +} + diff --git a/packages/longtable-scholar-research/src/reporting.ts b/packages/longtable-scholar-research/src/reporting.ts new file mode 100644 index 0000000..36c0e3a --- /dev/null +++ b/packages/longtable-scholar-research/src/reporting.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import type { CorpusCounts } from "./invariants.js"; +import { verifyCorpusInvariants } from "./invariants.js"; +import { renderPrismaCountTable, renderPrismaFlowSvg } from "./prisma.js"; +import type { ArtifactProvenance, ProtocolRevision, ResearchRun } from "./workflow-types.js"; + +export interface DatabaseYield { + readonly databaseId: string; + readonly searchedAt: string; + readonly resultCount: number; + readonly exportedCount: number; +} + +export interface ResearchFailureSummary { + readonly code: string; + readonly count: number; + readonly resolution: string; +} + +export interface ProtocolDeviation { + readonly id: string; + readonly description: string; + readonly decisionRecordId: string; +} + +export interface ResearchReportInput { + readonly run: ResearchRun; + readonly protocol: ProtocolRevision; + readonly counts: CorpusCounts; + readonly databaseYields: readonly DatabaseYield[]; + readonly failures: readonly ResearchFailureSummary[]; + readonly unresolvedIssues: readonly string[]; + readonly deviations: readonly ProtocolDeviation[]; + readonly analysisReadiness: string; + readonly requiredActions: readonly string[]; + readonly generatedArtifacts: readonly string[]; + readonly inputArtifactIds: readonly string[]; + readonly renderTimestamp: string; + readonly generatorVersion: string; +} + +export interface SystematicReviewNarrative { + readonly methodsMarkdown: string; + readonly resultsMarkdown?: string; +} + +export interface ResearchOutputs { + readonly researcherReportMarkdown: string; + readonly prismaCountTableMarkdown: string; + readonly prismaFlowSvg: string; + readonly systematicReviewMarkdown: string; + readonly artifactProvenance: readonly ArtifactProvenance[]; +} + +function bullets(values: readonly string[], empty: string): string { + return values.length > 0 ? values.map((value) => `- ${value}`).join("\n") : `- ${empty}`; +} + +function numberWord(value: number): string { + const words = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve"]; + return words[value] ?? String(value); +} + +export function renderSystematicReviewNarrative(input: { + readonly status: "planned" | "completed"; + readonly protocol: ProtocolRevision; + readonly counts: CorpusCounts; +}): SystematicReviewNarrative { + const databases = input.protocol.databases.join(", "); + if (input.status === "planned") { + return { + methodsMarkdown: `### Planned methods\n\n${databases} will be searched using the frozen database-specific queries and filters recorded in protocol revision ${input.protocol.revision}. Export, normalization, deduplication, screening, full-text acquisition, and synthesis decisions will be retained in append-only audit ledgers.` + }; + } + if (!verifyCorpusInvariants(input.counts).passed) { + throw new Error("Cannot render completed systematic review narrative because corpus count invariants failed."); + } + return { + methodsMarkdown: `### Methods\n\n${databases} were searched using the frozen database-specific queries and filters recorded in protocol revision ${input.protocol.revision}. Export, normalization, deduplication, screening, full-text acquisition, and synthesis decisions were retained in append-only audit ledgers.`, + resultsMarkdown: `### Results\n\n${numberWord(input.counts.identified)} records were identified. After normalization and duplicate linkage, ${input.counts.unique} unique records remained; ${input.counts.fulltextAssessed} reports were assessed in full text and ${input.counts.finalIncluded} studies were included.` + }; +} + +function renderResearcherReport(input: ResearchReportInput): string { + const yields = [...input.databaseYields] + .sort((left, right) => left.databaseId.localeCompare(right.databaseId)) + .map((entry) => `| ${entry.databaseId} | ${entry.searchedAt} | ${entry.resultCount} | ${entry.exportedCount} |`); + const failures = [...input.failures] + .sort((left, right) => left.code.localeCompare(right.code)) + .map((entry) => `- ${entry.code}: ${entry.count} occurrence(s); ${entry.resolution}`); + const deviations = [...input.deviations] + .sort((left, right) => left.id.localeCompare(right.id)) + .map((entry) => `- ${entry.id}: ${entry.description} (DecisionRecord: ${entry.decisionRecordId})`); + return [ + "# Researcher audit report", + "", + `Run: ${input.run.id}`, + `Rendered: ${input.renderTimestamp}`, + "", + "## Protocol and databases", + "", + `- Frozen protocol: ${input.protocol.id}, revision ${input.protocol.revision}, hash ${input.protocol.protocolHash}`, + ...input.protocol.databases.map((database) => `- ${database}: ${input.protocol.queries[database] ?? "query not recorded"}`), + "", + "## Database yields", + "", + "| Database | Searched at | Results | Exported |", + "|---|---|---:|---:|", + ...yields, + "", + "## Failures and recoveries", + "", + failures.length > 0 ? failures.join("\n") : "- None recorded.", + "", + "## Unresolved issues", + "", + bullets(input.unresolvedIssues, "None."), + "", + "## Deviations", + "", + deviations.length > 0 ? deviations.join("\n") : "- None recorded.", + "", + "## Analysis readiness", + "", + `- ${input.analysisReadiness}`, + "", + "## Required researcher actions", + "", + bullets(input.requiredActions, "None."), + "", + "## Generated artifacts", + "", + bullets(input.generatedArtifacts, "None."), + "" + ].join("\n"); +} + +function provenance(input: ResearchReportInput, kind: string, content: string): ArtifactProvenance { + const sha256 = createHash("sha256").update(content).digest("hex"); + return { + id: `artifact_${kind}_${sha256.slice(0, 16)}`, + sourceStage: "RESEARCHER_REPORT", + inputArtifactIds: [...input.inputArtifactIds].sort(), + protocolRevisionId: input.protocol.id, + generator: `longtable-research-${kind}`, + generatorVersion: input.generatorVersion, + sha256, + createdAt: input.renderTimestamp + }; +} + +export function renderResearchOutputs(input: ResearchReportInput): ResearchOutputs { + const audit = verifyCorpusInvariants(input.counts); + if (!audit.passed) { + throw new Error(`Cannot render research outputs because corpus count invariants failed: ${audit.equations.filter((entry) => !entry.passed).map((entry) => entry.expression).join(", ")}`); + } + const researcherReportMarkdown = renderResearcherReport(input); + const prismaCountTableMarkdown = renderPrismaCountTable(input.counts); + const prismaFlowSvg = renderPrismaFlowSvg(input.counts); + const narrative = renderSystematicReviewNarrative({ status: "completed", protocol: input.protocol, counts: input.counts }); + const systematicReviewMarkdown = [narrative.methodsMarkdown, narrative.resultsMarkdown].filter(Boolean).join("\n\n"); + const contents = [ + ["researcher-report", researcherReportMarkdown], + ["review-flow-table", prismaCountTableMarkdown], + ["review-flow-svg", prismaFlowSvg], + ["systematic-review", systematicReviewMarkdown] + ] as const; + return { + researcherReportMarkdown, + prismaCountTableMarkdown, + prismaFlowSvg, + systematicReviewMarkdown, + artifactProvenance: contents.map(([kind, content]) => provenance(input, kind, content)) + }; +} + diff --git a/scripts/smoke-research-reporting.mjs b/scripts/smoke-research-reporting.mjs new file mode 100644 index 0000000..8b3bab0 --- /dev/null +++ b/scripts/smoke-research-reporting.mjs @@ -0,0 +1,96 @@ +import { strict as assert } from "node:assert"; +import { resolve } from "node:path"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const research = await import(resolve(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); + +const counts = { + identified: 12, + normalized: 11, + parseRejected: 1, + unique: 9, + duplicateLinks: 2, + titleAbstractScreened: 9, + fulltextCandidates: 5, + titleAbstractExcluded: 3, + titleAbstractPending: 1, + fulltextSought: 5, + fulltextRetrieved: 4, + fulltextNotRetrieved: 1, + fulltextAssessed: 4, + finalIncluded: 2, + fulltextExcluded: 2, + unresolved: 0 +}; + +const input = { + run: { + id: "run-reporting", + createdAt: "2026-08-06T06:00:00.000Z", + updatedAt: "2026-08-06T07:00:00.000Z", + status: "completed", + stage: "RESEARCHER_REPORT", + protocolRevisionId: "protocol-1", + institutionProfileId: "psu-sanitized" + }, + protocol: { + id: "protocol-1", + revision: 1, + frozenAt: "2026-08-06T06:10:00.000Z", + decisionRecordId: "decision-protocol-1", + databases: ["web-of-science", "scopus"], + queries: { + "web-of-science": "TS=(hackathon AND workplace competency)", + scopus: "TITLE-ABS-KEY(hackathon AND workplace competency)" + }, + filters: { years: [2015, 2026], languages: ["English", "Korean"], publicationTypes: ["Article"] }, + protocolHash: "a".repeat(64) + }, + counts, + databaseYields: [ + { databaseId: "scopus", searchedAt: "2026-08-06T06:31:00.000Z", resultCount: 5, exportedCount: 5 }, + { databaseId: "web-of-science", searchedAt: "2026-08-06T06:30:00.000Z", resultCount: 7, exportedCount: 7 } + ], + failures: [{ code: "DELAYED_EXPORT_GENERATION", count: 1, resolution: "retry_succeeded" }], + unresolvedIssues: [], + deviations: [{ id: "deviation-1", description: "One export was regenerated.", decisionRecordId: "decision-export-1" }], + analysisReadiness: "ready", + requiredActions: ["Archive the frozen corpus with the manuscript package."], + generatedArtifacts: ["corpus/frozen-corpus.jsonl"], + inputArtifactIds: ["corpus-frozen-1", "screening-ledger-1"], + renderTimestamp: "2026-08-06T07:30:00.000Z", + generatorVersion: "0.1.72" +}; + +const first = research.renderResearchOutputs(input); +const second = research.renderResearchOutputs(input); +assert.deepEqual(first, second, "fixed state and timestamp must render byte-for-byte deterministically"); +assert.match(first.researcherReportMarkdown, /## Protocol and databases/); +assert.match(first.researcherReportMarkdown, /web-of-science/); +assert.match(first.researcherReportMarkdown, /## Database yields/); +assert.match(first.researcherReportMarkdown, /## Failures and recoveries/); +assert.match(first.researcherReportMarkdown, /## Unresolved issues/); +assert.match(first.researcherReportMarkdown, /## Deviations/); +assert.match(first.researcherReportMarkdown, /## Analysis readiness/); +assert.match(first.researcherReportMarkdown, /## Required researcher actions/); +assert.match(first.researcherReportMarkdown, /## Generated artifacts/); +assert.match(first.prismaCountTableMarkdown, /Identified records.*12/); +assert.match(first.prismaFlowSvg, / entry.protocolRevisionId === "protocol-1")); +assert(first.artifactProvenance.every((entry) => /^[a-f0-9]{64}$/.test(entry.sha256))); + +const planned = research.renderSystematicReviewNarrative({ status: "planned", protocol: input.protocol, counts }); +assert.match(planned.methodsMarkdown, /will be searched/); +assert.equal(planned.resultsMarkdown, undefined); +const completed = research.renderSystematicReviewNarrative({ status: "completed", protocol: input.protocol, counts }); +assert.match(completed.methodsMarkdown, /were searched/); +assert.match(completed.resultsMarkdown, /Twelve records were identified/); + +assert.throws(() => research.renderResearchOutputs({ + ...input, + counts: { ...counts, finalIncluded: 3 } +}), /corpus count invariants/i); + +console.log("research reporting tests passed"); From 11787a76b9beea61b862aabe28646912eb44f086 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:11:10 +0900 Subject: [PATCH 17/25] feat(research): render APA7 and journal manuscript packages --- package.json | 3 +- .../fixtures/render-profiles/apa7.json | 19 ++ .../render-profiles/korean-journal.json | 19 ++ .../longtable-scholar-research/package.json | 7 + .../resources/render_word.py | 190 ++++++++++++++++++ .../longtable-scholar-research/src/index.ts | 4 + .../src/manuscript.ts | 115 +++++++++++ .../src/references.ts | 109 ++++++++++ .../src/render-profiles.ts | 54 +++++ .../src/word-renderer.ts | 47 +++++ scripts/smoke-manuscript-package.mjs | 116 +++++++++++ 11 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 packages/longtable-scholar-research/fixtures/render-profiles/apa7.json create mode 100644 packages/longtable-scholar-research/fixtures/render-profiles/korean-journal.json create mode 100644 packages/longtable-scholar-research/resources/render_word.py create mode 100644 packages/longtable-scholar-research/src/manuscript.ts create mode 100644 packages/longtable-scholar-research/src/references.ts create mode 100644 packages/longtable-scholar-research/src/render-profiles.ts create mode 100644 packages/longtable-scholar-research/src/word-renderer.ts create mode 100644 scripts/smoke-manuscript-package.mjs diff --git a/package.json b/package.json index f7290c2..05e4bd7 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "smoke:browser-recipes": "node scripts/smoke-browser-recipes.mjs", "smoke:screening": "node scripts/smoke-screening.mjs", "smoke:research-reporting": "node scripts/smoke-research-reporting.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run smoke:browser-recipes && npm run smoke:screening && npm run smoke:research-reporting && npm run typecheck", + "smoke:manuscript-package": "node scripts/smoke-manuscript-package.mjs", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run smoke:browser-recipes && npm run smoke:screening && npm run smoke:research-reporting && npm run smoke:manuscript-package && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, diff --git a/packages/longtable-scholar-research/fixtures/render-profiles/apa7.json b/packages/longtable-scholar-research/fixtures/render-profiles/apa7.json new file mode 100644 index 0000000..f493c0b --- /dev/null +++ b/packages/longtable-scholar-research/fixtures/render-profiles/apa7.json @@ -0,0 +1,19 @@ +{ + "id": "apa7-memory", + "label": "APA 7 working manuscript (memory/template tokens)", + "version": 1, + "status": "verified", + "sourceBasis": "User-requested APA 7 memory format; exact journal rules remain external to this profile.", + "page": { "size": "Letter", "marginInches": 1, "headerInches": 0.5, "footerInches": 0.5 }, + "body": { + "latinFont": "Times New Roman", + "eastAsiaFont": "Times New Roman", + "fontSizePt": 12, + "lineSpacing": 2, + "paragraphAfterPt": 0, + "firstLineIndentInches": 0.5 + }, + "references": { "hangingIndentInches": 0.5 }, + "pageNumber": "top_right", + "titlePage": true +} diff --git a/packages/longtable-scholar-research/fixtures/render-profiles/korean-journal.json b/packages/longtable-scholar-research/fixtures/render-profiles/korean-journal.json new file mode 100644 index 0000000..33be3ac --- /dev/null +++ b/packages/longtable-scholar-research/fixtures/render-profiles/korean-journal.json @@ -0,0 +1,19 @@ +{ + "id": "korean-journal-provisional", + "label": "General Korean journal working manuscript", + "version": 1, + "status": "provisional_until_journal_rules_verified", + "sourceBasis": "Generic Korean-language working profile; replace only after journal author instructions and sample articles are verified.", + "page": { "size": "A4", "marginInches": 1, "headerInches": 0.5, "footerInches": 0.5 }, + "body": { + "latinFont": "Times New Roman", + "eastAsiaFont": "Batang", + "fontSizePt": 10.5, + "lineSpacing": 1.6, + "paragraphAfterPt": 0, + "firstLineIndentInches": 0.4 + }, + "references": { "hangingIndentInches": 0.4 }, + "pageNumber": "bottom_center", + "titlePage": false +} diff --git a/packages/longtable-scholar-research/package.json b/packages/longtable-scholar-research/package.json index 053331f..7c5abfc 100644 --- a/packages/longtable-scholar-research/package.json +++ b/packages/longtable-scholar-research/package.json @@ -14,6 +14,8 @@ }, "files": [ "dist", + "resources", + "fixtures/render-profiles", "README.md" ], "scripts": { @@ -46,5 +48,10 @@ }, "engines": { "node": ">=18.0.0" + }, + "longtableDocumentRuntime": { + "library": "python-docx", + "version": "1.2.0", + "pythonModule": "docx" } } diff --git a/packages/longtable-scholar-research/resources/render_word.py b/packages/longtable-scholar-research/resources/render_word.py new file mode 100644 index 0000000..184f632 --- /dev/null +++ b/packages/longtable-scholar-research/resources/render_word.py @@ -0,0 +1,190 @@ +import json +import re +import sys +from pathlib import Path + +from docx import Document +from docx.enum.section import WD_SECTION +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Inches, Pt + + +def set_run_font(run, latin, east_asia, size): + run.font.name = latin + run.font.size = Pt(size) + run._element.get_or_add_rPr().rFonts.set(qn("w:ascii"), latin) + run._element.get_or_add_rPr().rFonts.set(qn("w:hAnsi"), latin) + run._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), east_asia) + + +def add_page_number(paragraph): + run = paragraph.add_run() + begin = OxmlElement("w:fldChar") + begin.set(qn("w:fldCharType"), "begin") + instruction = OxmlElement("w:instrText") + instruction.set(qn("xml:space"), "preserve") + instruction.text = " PAGE " + separate = OxmlElement("w:fldChar") + separate.set(qn("w:fldCharType"), "separate") + text = OxmlElement("w:t") + text.text = "1" + end = OxmlElement("w:fldChar") + end.set(qn("w:fldCharType"), "end") + for item in (begin, instruction, separate, text, end): + run._r.append(item) + + +def style_document(doc, profile): + section = doc.sections[0] + section.page_width = Inches(8.5 if profile["page"]["size"] == "Letter" else 8.27) + section.page_height = Inches(11 if profile["page"]["size"] == "Letter" else 11.69) + margin = Inches(profile["page"]["marginInches"]) + section.top_margin = section.bottom_margin = section.left_margin = section.right_margin = margin + section.header_distance = Inches(profile["page"]["headerInches"]) + section.footer_distance = Inches(profile["page"]["footerInches"]) + body = profile["body"] + normal = doc.styles["Normal"] + normal.font.name = body["latinFont"] + normal.font.size = Pt(body["fontSizePt"]) + normal._element.get_or_add_rPr().rFonts.set(qn("w:ascii"), body["latinFont"]) + normal._element.get_or_add_rPr().rFonts.set(qn("w:hAnsi"), body["latinFont"]) + normal._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), body["eastAsiaFont"]) + normal.paragraph_format.line_spacing = body["lineSpacing"] + normal.paragraph_format.space_after = Pt(body["paragraphAfterPt"]) + for style_name in ("Heading 1", "Heading 2"): + style = doc.styles[style_name] + style.font.name = body["latinFont"] + style.font.size = Pt(body["fontSizePt"]) + style.font.bold = True + style.font.color.rgb = None + style._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), body["eastAsiaFont"]) + style.paragraph_format.line_spacing = body["lineSpacing"] + style.paragraph_format.space_before = Pt(12) + style.paragraph_format.space_after = Pt(0) + style.paragraph_format.keep_with_next = True + page_paragraph = section.header.paragraphs[0] if profile["pageNumber"] == "top_right" else section.footer.paragraphs[0] + page_paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT if profile["pageNumber"] == "top_right" else WD_ALIGN_PARAGRAPH.CENTER + add_page_number(page_paragraph) + + +def add_centered(doc, text, bold=False): + paragraph = doc.add_paragraph() + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = paragraph.add_run(text) + run.bold = bold + return paragraph + + +def add_body(doc, text, profile, indent=True): + paragraph = doc.add_paragraph() + if indent: + paragraph.paragraph_format.first_line_indent = Inches(profile["body"]["firstLineIndentInches"]) + paragraph.add_run(text) + return paragraph + + +def set_table_geometry(table, widths): + table.autofit = False + tbl_pr = table._tbl.tblPr + tbl_w = tbl_pr.first_child_found_in("w:tblW") + tbl_w.set(qn("w:type"), "dxa") + tbl_w.set(qn("w:w"), str(sum(widths))) + tbl_ind = OxmlElement("w:tblInd") + tbl_ind.set(qn("w:type"), "dxa") + tbl_ind.set(qn("w:w"), "120") + tbl_pr.append(tbl_ind) + grid = table._tbl.tblGrid + for child in list(grid): + grid.remove(child) + for width in widths: + col = OxmlElement("w:gridCol") + col.set(qn("w:w"), str(width)) + grid.append(col) + for row in table.rows: + for cell, width in zip(row.cells, widths): + cell.width = Inches(width / 1440) + tc_w = cell._tc.get_or_add_tcPr().first_child_found_in("w:tcW") + tc_w.set(qn("w:type"), "dxa") + tc_w.set(qn("w:w"), str(width)) + + +def add_readiness_table(doc, manuscript): + rows = [ + ("점검 항목", "상태"), + ("포함 연구", f'{len(manuscript["references"])}편'), + ("효과크기 산출 가능성", "승인된 분석계획에 따라 별도 추출"), + ("표본 및 출판물 의존성", "코딩 필요"), + ("결측 및 저자 연락", "코딩 필요"), + ] + table = doc.add_table(rows=len(rows), cols=2) + table.style = "Table Grid" + for row_index, values in enumerate(rows): + for column_index, value in enumerate(values): + table.cell(row_index, column_index).text = value + if row_index == 0: + for run in table.cell(row_index, column_index).paragraphs[0].runs: + run.bold = True + set_table_geometry(table, [3000, 6360]) + + +def add_markdown_italic_runs(paragraph, text): + for index, part in enumerate(re.split(r"\*([^*]+)\*", text)): + if not part: + continue + run = paragraph.add_run(part) + run.italic = index % 2 == 1 + + +def build(payload, output_path): + manuscript = payload["manuscript"] + profile = payload["profile"] + template_path = profile.get("templatePath") + doc = Document(template_path) if template_path else Document() + style_document(doc, profile) + if profile["titlePage"]: + for _ in range(5): + doc.add_paragraph() + add_centered(doc, manuscript["title"], bold=True) + doc.add_paragraph() + for line in manuscript["authorLines"]: + add_centered(doc, line) + doc.add_page_break() + else: + add_centered(doc, manuscript["title"], bold=True) + for line in manuscript["authorLines"]: + add_centered(doc, line) + add_centered(doc, "초록" if manuscript["language"] == "ko" else "Abstract", bold=True) + add_body(doc, manuscript["abstract"], profile, indent=False) + keywords = doc.add_paragraph() + label = keywords.add_run("주제어: " if manuscript["language"] == "ko" else "Keywords: ") + label.italic = True + keywords.add_run(", ".join(manuscript["keywords"])) + keywords.paragraph_format.space_after = Pt(12) + for section in manuscript["sections"]: + heading = doc.add_paragraph(style="Heading 1") + heading.alignment = WD_ALIGN_PARAGRAPH.CENTER + heading.paragraph_format.space_before = Pt(12) + heading.paragraph_format.space_after = Pt(0) + heading.paragraph_format.keep_with_next = True + heading.add_run(section["heading"]) + add_body(doc, section["content"], profile) + if section["id"] == "methods": + add_readiness_table(doc, manuscript) + add_centered(doc, "참고문헌" if manuscript["language"] == "ko" else "References", bold=True) + hanging = profile["references"]["hangingIndentInches"] + for reference in manuscript["references"]: + paragraph = doc.add_paragraph() + paragraph.paragraph_format.left_indent = Inches(hanging) + paragraph.paragraph_format.first_line_indent = Inches(-hanging) + add_markdown_italic_runs(paragraph, reference) + doc.core_properties.title = manuscript["title"] + doc.core_properties.subject = f'LongTable protocol {manuscript["protocol"]["id"]}' + doc.core_properties.comments = f'Render profile {profile["id"]}; generated {manuscript["generatedAt"]}' + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + doc.save(output_path) + + +if __name__ == "__main__": + build(json.load(sys.stdin), sys.argv[1]) diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index 58a4eb4..13cd973 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -19,3 +19,7 @@ export * from "./browser-recipes.js"; export * from "./screening.js"; export * from "./prisma.js"; export * from "./reporting.js"; +export * from "./references.js"; +export * from "./render-profiles.js"; +export * from "./manuscript.js"; +export * from "./word-renderer.js"; diff --git a/packages/longtable-scholar-research/src/manuscript.ts b/packages/longtable-scholar-research/src/manuscript.ts new file mode 100644 index 0000000..7e131dc --- /dev/null +++ b/packages/longtable-scholar-research/src/manuscript.ts @@ -0,0 +1,115 @@ +import { createHash } from "node:crypto"; +import type { CanonicalPaper } from "./corpus.js"; +import type { CorpusCounts } from "./invariants.js"; +import { verifyCorpusInvariants } from "./invariants.js"; +import { renderReferenceFormats } from "./references.js"; +import type { ProtocolRevision } from "./workflow-types.js"; + +export interface ManuscriptSection { + readonly id: string; + readonly heading: string; + readonly content: string; +} + +export interface ManuscriptProvenanceEntry { + readonly objectId: string; + readonly objectType: "paragraph" | "table" | "reference"; + readonly sha256: string; + readonly inputArtifactIds: readonly string[]; + readonly protocolRevisionId: string; +} + +export interface ApprovedManuscript { + readonly title: string; + readonly authorLines: readonly string[]; + readonly language: "ko" | "en"; + readonly abstract: string; + readonly keywords: readonly string[]; + readonly protocol: ProtocolRevision; + readonly sections: readonly ManuscriptSection[]; + readonly references: readonly string[]; + readonly unresolvedCitations: readonly { readonly paperId: string; readonly missingFields: readonly string[] }[]; + readonly metaAnalysisReadinessMarkdown: string; + readonly provenanceMap: readonly ManuscriptProvenanceEntry[]; + readonly generatedAt: string; + readonly generatorVersion: string; +} + +export interface BuildApprovedManuscriptInput { + readonly title: string; + readonly authorLines: readonly string[]; + readonly language: "ko" | "en"; + readonly abstract?: string; + readonly keywords: readonly string[]; + readonly protocol: ProtocolRevision; + readonly counts: CorpusCounts; + readonly papers: readonly CanonicalPaper[]; + readonly inputArtifactIds: readonly string[]; + readonly generatedAt: string; + readonly generatorVersion: string; + readonly approvals: { + readonly databasesExecuted: boolean; + readonly fulltextAvailabilityVerified: boolean; + readonly analysisApproved: boolean; + }; + readonly analysisPlan: string; +} + +function hash(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function readinessTable(papers: readonly CanonicalPaper[], analysisPlan: string): string { + const isMeta = /meta/i.test(analysisPlan); + return [ + "| 점검 항목 | 상태 | 근거 |", + "|---|---|---|", + `| 포함 연구 수 | ${papers.length}편 | 동결 코퍼스 |`, + `| 효과크기 산출 가능성 | ${isMeta ? "개별 연구 통계량 추출 필요" : "현재 분석계획의 직접 산출 대상 아님"} | 승인된 분석계획: ${analysisPlan} |`, + "| 표본·출판물 의존성 | 코딩 필요 | 저자·표본·데이터셋 중복 점검표 |", + "| 결측 및 저자 연락 | 코딩 필요 | 전문 추출 ledger |" + ].join("\n"); +} + +export function buildApprovedManuscript(input: BuildApprovedManuscriptInput): ApprovedManuscript { + if (!input.approvals.databasesExecuted) throw new Error("Cannot render a manuscript that claims an unexecuted database search."); + if (!input.approvals.fulltextAvailabilityVerified) throw new Error("Cannot render a manuscript before full-text availability is verified."); + if (!input.approvals.analysisApproved) throw new Error("Cannot render an unapproved analysis."); + if (!verifyCorpusInvariants(input.counts).passed) throw new Error("Cannot render a manuscript because corpus count invariants failed."); + const references = renderReferenceFormats(input.papers); + const databases = input.protocol.databases.join(", "); + const sections: ManuscriptSection[] = input.language === "ko" ? [ + { id: "introduction", heading: "서론", content: "본 연구는 해커톤형 집중 프로그램과 직무역량 개발을 수요-공급 불일치의 관점에서 검토한다. 연구문제와 개념적 틀은 동결 프로토콜의 범위 안에서 해석한다." }, + { id: "methods", heading: "연구방법", content: `${databases}를 동결된 데이터베이스별 검색식과 포함·배제 기준으로 검색하였다. 메타데이터 내보내기, 정규화, 중복 연결, 제목·초록 선별, 전문 확보 및 전문 선별의 모든 판정을 append-only ledger에 기록하였다.` }, + { id: "results", heading: "결과", content: `총 ${input.counts.identified}건을 식별하였고, 중복 연결 후 ${input.counts.unique}건을 선별하였다. 전문 ${input.counts.fulltextAssessed}건을 평가하여 최종 ${input.counts.finalIncluded}편을 포함하였다.` }, + { id: "discussion", heading: "논의", content: "결과 해석은 동결 코퍼스와 승인된 분석계획에 한정한다. 직무역량 수요와 교육 공급의 불일치는 후속 문서 코딩 및 개념 매핑을 통해 검증한다." } + ] : [ + { id: "introduction", heading: "Introduction", content: "This review examines hackathon-like intensive protocols and workplace competency development through a demand-supply mismatch lens." }, + { id: "methods", heading: "Method", content: `${databases} were searched using frozen database-specific queries and eligibility criteria. Export, normalization, duplicate linkage, screening, full-text acquisition, and synthesis decisions were retained in append-only ledgers.` }, + { id: "results", heading: "Results", content: `${input.counts.identified} records were identified; ${input.counts.unique} unique records were screened, ${input.counts.fulltextAssessed} reports were assessed in full text, and ${input.counts.finalIncluded} studies were included.` }, + { id: "discussion", heading: "Discussion", content: "Interpretation is limited to the frozen corpus and approved analysis plan. Demand-supply mismatch requires explicit document coding and construct mapping." } + ]; + const metaAnalysisReadinessMarkdown = readinessTable(input.papers, input.analysisPlan); + const inputIds = [...input.inputArtifactIds].sort(); + const provenanceMap: ManuscriptProvenanceEntry[] = [ + ...sections.map((section) => ({ objectId: section.id, objectType: "paragraph" as const, sha256: hash(section.content), inputArtifactIds: inputIds, protocolRevisionId: input.protocol.id })), + { objectId: "meta-analysis-readiness", objectType: "table", sha256: hash(metaAnalysisReadinessMarkdown), inputArtifactIds: inputIds, protocolRevisionId: input.protocol.id }, + ...references.apa7References.map((reference, index) => ({ objectId: `reference-${index + 1}`, objectType: "reference" as const, sha256: hash(reference), inputArtifactIds: inputIds, protocolRevisionId: input.protocol.id })) + ]; + return { + title: input.title, + authorLines: [...input.authorLines], + language: input.language, + abstract: input.abstract ?? "[초록은 승인된 연구 결과와 해석을 반영하여 작성해야 합니다.]", + keywords: [...input.keywords], + protocol: input.protocol, + sections, + references: references.apa7References, + unresolvedCitations: references.unresolvedCitations, + metaAnalysisReadinessMarkdown, + provenanceMap, + generatedAt: input.generatedAt, + generatorVersion: input.generatorVersion + }; +} + diff --git a/packages/longtable-scholar-research/src/references.ts b/packages/longtable-scholar-research/src/references.ts new file mode 100644 index 0000000..ae10f55 --- /dev/null +++ b/packages/longtable-scholar-research/src/references.ts @@ -0,0 +1,109 @@ +import type { CanonicalPaper } from "./corpus.js"; + +export interface UnresolvedCitation { + readonly paperId: string; + readonly missingFields: readonly string[]; +} + +export interface ReferenceFormats { + readonly cslJson: readonly Record[]; + readonly ris: string; + readonly bibtex: string; + readonly apa7References: readonly string[]; + readonly unresolvedCitations: readonly UnresolvedCitation[]; + readonly unresolvedCitationReportMarkdown: string; +} + +function sortedPapers(papers: readonly CanonicalPaper[]): CanonicalPaper[] { + return [...papers].sort((left, right) => + (left.authors[0] ?? "").localeCompare(right.authors[0] ?? "") || + (left.year ?? 0) - (right.year ?? 0) || + left.title.localeCompare(right.title) || + left.paperId.localeCompare(right.paperId) + ); +} + +function authorParts(author: string): { family?: string; given?: string; literal?: string } { + const [family, ...givenParts] = author.split(",").map((part) => part.trim()); + if (givenParts.length === 0) return { literal: author }; + return { family, given: givenParts.join(", ") }; +} + +function initials(value: string): string { + return value.split(/[\s-]+/).filter(Boolean).map((part) => `${part[0]?.toUpperCase() ?? ""}.`).join(" "); +} + +function apaAuthors(authors: readonly string[]): string { + const rendered = authors.map((author) => { + const parts = authorParts(author); + return parts.literal ?? `${parts.family}, ${initials(parts.given ?? "")}`; + }); + if (rendered.length <= 1) return rendered[0] ?? "Unknown author"; + if (rendered.length === 2) return `${rendered[0]}, & ${rendered[1]}`; + return `${rendered.slice(0, -1).join(", ")}, & ${rendered.at(-1)}`; +} + +function bibtexKey(paper: CanonicalPaper): string { + const lead = (paper.authors[0] ?? "unknown").split(",")[0].normalize("NFKD").replace(/[^\p{L}\p{N}]/gu, ""); + return `${lead || "unknown"}${paper.year ?? "nd"}_${paper.paperId.replace(/^paper_?/, "").slice(0, 8)}`; +} + +function escapeBibtex(value: string): string { + return value.replaceAll("{", "\\{").replaceAll("}", "\\}"); +} + +export function renderReferenceFormats(papers: readonly CanonicalPaper[]): ReferenceFormats { + const ordered = sortedPapers(papers); + const cslJson = ordered.map((paper) => ({ + id: paper.paperId, + type: "article-journal", + title: paper.title, + author: paper.authors.map(authorParts), + ...(paper.year ? { issued: { "date-parts": [[paper.year]] } } : {}), + ...(paper.venue ? { "container-title": paper.venue } : {}), + ...(paper.doi ? { DOI: paper.doi } : {}) + })); + const ris = ordered.map((paper) => [ + "TY - JOUR", + `ID - ${paper.paperId}`, + ...paper.authors.map((author) => `AU - ${author}`), + `TI - ${paper.title}`, + ...(paper.year ? [`PY - ${paper.year}`] : []), + ...(paper.venue ? [`JO - ${paper.venue}`] : []), + ...(paper.doi ? [`DO - ${paper.doi}`] : []), + "ER -" + ].join("\n")).join("\n\n"); + const bibtex = ordered.map((paper) => [ + `@article{${bibtexKey(paper)},`, + ` title = {${escapeBibtex(paper.title)}},`, + ` author = {${paper.authors.map(escapeBibtex).join(" and ")}},`, + ...(paper.year ? [` year = {${paper.year}},`] : []), + ...(paper.venue ? [` journal = {${escapeBibtex(paper.venue)}},`] : []), + ...(paper.doi ? [` doi = {${paper.doi}},`] : []), + ` longtable_id = {${paper.paperId}}`, + "}" + ].join("\n")).join("\n\n"); + const apa7References = ordered.map((paper) => { + const year = paper.year ? String(paper.year) : "n.d."; + const venue = paper.venue ? ` *${paper.venue}*.` : ""; + const doi = paper.doi ? ` https://doi.org/${paper.doi}` : ""; + return `${apaAuthors(paper.authors)} (${year}). ${paper.title}.${venue}${doi}`; + }); + const unresolvedCitations = ordered.flatMap((paper) => { + const missingFields = [ + ...(paper.authors.length === 0 ? ["authors"] : []), + ...(!paper.year ? ["year"] : []), + ...(!paper.venue ? ["venue"] : []), + ...(!paper.doi ? ["doi"] : []) + ]; + return missingFields.length > 0 ? [{ paperId: paper.paperId, missingFields }] : []; + }); + const unresolvedCitationReportMarkdown = [ + "# Unresolved citation fields", + "", + ...(unresolvedCitations.length > 0 + ? unresolvedCitations.map((entry) => `- ${entry.paperId}: ${entry.missingFields.join(", ")}`) + : ["- None."]) + ].join("\n"); + return { cslJson, ris, bibtex, apa7References, unresolvedCitations, unresolvedCitationReportMarkdown }; +} diff --git a/packages/longtable-scholar-research/src/render-profiles.ts b/packages/longtable-scholar-research/src/render-profiles.ts new file mode 100644 index 0000000..4738fb9 --- /dev/null +++ b/packages/longtable-scholar-research/src/render-profiles.ts @@ -0,0 +1,54 @@ +export interface WordRenderProfile { + readonly id: string; + readonly label: string; + readonly version: number; + readonly status: "verified" | "provisional_until_journal_rules_verified"; + readonly sourceBasis: string; + readonly page: { + readonly size: "Letter" | "A4"; + readonly marginInches: number; + readonly headerInches: number; + readonly footerInches: number; + }; + readonly body: { + readonly latinFont: string; + readonly eastAsiaFont: string; + readonly fontSizePt: number; + readonly lineSpacing: number; + readonly paragraphAfterPt: number; + readonly firstLineIndentInches: number; + }; + readonly references: { + readonly hangingIndentInches: number; + }; + readonly pageNumber: "top_right" | "bottom_center"; + readonly titlePage: boolean; + readonly templatePath?: string; + readonly templateSha256?: string; +} + +function positive(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${label} must be a positive number.`); + return value; +} + +export function validateRenderProfile(input: unknown): WordRenderProfile { + if (!input || typeof input !== "object") throw new Error("Render profile must be an object."); + const profile = input as Partial; + if (!profile.id?.trim() || !profile.label?.trim() || !profile.sourceBasis?.trim()) throw new Error("Render profile id, label, and source basis are required."); + if (!Number.isInteger(profile.version) || (profile.version ?? 0) < 1) throw new Error("Render profile version must be a positive integer."); + if (!profile.page || !["Letter", "A4"].includes(profile.page.size)) throw new Error("Render profile page size must be Letter or A4."); + positive(profile.page.marginInches, "Render profile margin"); + positive(profile.page.headerInches, "Render profile header distance"); + positive(profile.page.footerInches, "Render profile footer distance"); + if (!profile.body?.latinFont?.trim() || !profile.body.eastAsiaFont?.trim()) throw new Error("Render profile body fonts are required."); + positive(profile.body.fontSizePt, "Render profile font size"); + positive(profile.body.lineSpacing, "Render profile line spacing"); + if (typeof profile.body.paragraphAfterPt !== "number" || profile.body.paragraphAfterPt < 0) throw new Error("Render profile paragraph spacing must be non-negative."); + if (typeof profile.body.firstLineIndentInches !== "number" || profile.body.firstLineIndentInches < 0) throw new Error("Render profile first-line indent must be non-negative."); + positive(profile.references?.hangingIndentInches, "Render profile reference hanging indent"); + if (!profile.templatePath && profile.templateSha256) throw new Error("A template hash requires a user-supplied template path."); + if (profile.templatePath && !/^[a-f0-9]{64}$/.test(profile.templateSha256 ?? "")) throw new Error("A user-supplied template profile requires a SHA-256 hash."); + return JSON.parse(JSON.stringify(profile)) as WordRenderProfile; +} + diff --git a/packages/longtable-scholar-research/src/word-renderer.ts b/packages/longtable-scholar-research/src/word-renderer.ts new file mode 100644 index 0000000..a10e37f --- /dev/null +++ b/packages/longtable-scholar-research/src/word-renderer.ts @@ -0,0 +1,47 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import type { ApprovedManuscript } from "./manuscript.js"; +import type { WordRenderProfile } from "./render-profiles.js"; + +export interface WordRenderInput { + readonly manuscript: ApprovedManuscript; + readonly profile: WordRenderProfile; + readonly outputPath: string; + readonly pythonExecutable?: string; +} + +export interface WordRenderResult { + readonly outputPath: string; + readonly contentSha256: string; + readonly profileId: string; +} + +function stable(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, stable(entry)])); + } + return value; +} + +export function renderWordManuscript(input: WordRenderInput): WordRenderResult { + const here = dirname(fileURLToPath(import.meta.url)); + const renderer = resolve(here, "..", "resources", "render_word.py"); + if (!existsSync(renderer)) throw new Error(`DOCX renderer resource is missing: ${renderer}`); + if (input.profile.templatePath) { + if (!existsSync(input.profile.templatePath)) throw new Error(`User-supplied DOCX template does not exist: ${input.profile.templatePath}`); + const actualTemplateSha256 = createHash("sha256").update(readFileSync(input.profile.templatePath)).digest("hex"); + if (actualTemplateSha256 !== input.profile.templateSha256) throw new Error("User-supplied DOCX template hash does not match the validated render profile."); + } + const payload = JSON.stringify(stable({ manuscript: input.manuscript, profile: input.profile })); + const result = spawnSync(input.pythonExecutable ?? "python3", [renderer, input.outputPath], { input: payload, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); + if (result.status !== 0) throw new Error(`DOCX rendering failed: ${(result.stderr || result.stdout || "unknown renderer error").trim()}`); + return { + outputPath: input.outputPath, + contentSha256: createHash("sha256").update(payload).digest("hex"), + profileId: input.profile.id + }; +} diff --git a/scripts/smoke-manuscript-package.mjs b/scripts/smoke-manuscript-package.mjs new file mode 100644 index 0000000..3bb7f73 --- /dev/null +++ b/scripts/smoke-manuscript-package.mjs @@ -0,0 +1,116 @@ +import { strict as assert } from "node:assert"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const research = await import(resolve(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); +const fixtureRoot = join(repoRoot, "packages", "longtable-scholar-research", "fixtures", "render-profiles"); + +const papers = [ + { + paperId: "paper-a", + title: "Hackathons and workplace competency development", + normalizedTitle: "hackathons and workplace competency development", + authors: ["Kim, Mina", "You, Hosung"], + year: 2025, + doi: "10.1000/example-a", + venue: "Journal of Workplace Learning", + keywords: ["hackathon", "workplace competency"], + sourceLinks: [{ sourceDatabase: "web-of-science", sourceRecordId: "WOS:A", sourceRow: 2, exportArtifactSha256: "a".repeat(64) }], + provenanceBasis: ["web-of-science:WOS:A"] + }, + { + paperId: "paper-b", + title: "직무역량 강화를 위한 집중형 문제해결 프로그램", + normalizedTitle: "직무역량 강화를 위한 집중형 문제해결 프로그램", + authors: ["박지수"], + year: 2024, + venue: "직업교육연구", + keywords: ["직무역량"], + sourceLinks: [{ sourceDatabase: "riss", sourceRecordId: "RISS:B", sourceRow: 3, exportArtifactSha256: "b".repeat(64) }], + provenanceBasis: ["riss:RISS:B"] + } +]; + +const refsA = research.renderReferenceFormats(papers); +const refsB = research.renderReferenceFormats([...papers].reverse()); +assert.deepEqual(refsA, refsB); +assert.equal(refsA.cslJson.length, 2); +assert.match(refsA.ris, /TY - JOUR/); +assert.match(refsA.bibtex, /@article\{/); +assert.match(refsA.apa7References.join("\n"), /https:\/\/doi.org\/10.1000\/example-a/); +assert(refsA.unresolvedCitations.some((entry) => entry.paperId === "paper-b" && entry.missingFields.includes("doi"))); +assert.match(refsA.unresolvedCitationReportMarkdown, /paper-b: doi/); + +const apaProfile = research.validateRenderProfile(JSON.parse(await readFile(join(fixtureRoot, "apa7.json"), "utf8"))); +assert.equal(apaProfile.id, "apa7-memory"); +assert.equal(apaProfile.body.lineSpacing, 2); +const koreanProfile = research.validateRenderProfile(JSON.parse(await readFile(join(fixtureRoot, "korean-journal.json"), "utf8"))); +assert.equal(koreanProfile.status, "provisional_until_journal_rules_verified"); +assert.throws(() => research.validateRenderProfile({ ...apaProfile, page: { ...apaProfile.page, marginInches: 0 } }), /margin/i); +const userTemplateProfile = research.validateRenderProfile({ ...apaProfile, id: "user-template", templatePath: "/tmp/template.docx", templateSha256: "d".repeat(64) }); +assert.equal(userTemplateProfile.templateSha256, "d".repeat(64)); +assert.throws(() => research.validateRenderProfile({ ...apaProfile, id: "invalid-template", templatePath: "/tmp/template.docx" }), /SHA-256/i); + +const counts = { + identified: 2, normalized: 2, parseRejected: 0, unique: 2, duplicateLinks: 0, + titleAbstractScreened: 2, fulltextCandidates: 2, titleAbstractExcluded: 0, titleAbstractPending: 0, + fulltextSought: 2, fulltextRetrieved: 2, fulltextNotRetrieved: 0, + fulltextAssessed: 2, finalIncluded: 2, fulltextExcluded: 0, unresolved: 0 +}; +const manuscript = research.buildApprovedManuscript({ + title: "해커톤형 집중 프로그램과 직무역량 수요-공급 불일치: 체계적 문서분석 프로토콜", + authorLines: ["유호성", "소속 미정"], + language: "ko", + keywords: ["해커톤", "직무역량", "수요-공급 불일치", "문서분석"], + protocol: { + id: "protocol-1", revision: 1, frozenAt: "2026-08-06T06:10:00.000Z", decisionRecordId: "decision-protocol-1", + databases: ["web-of-science", "riss"], + queries: { "web-of-science": "TS=(hackathon AND competency)", riss: "해커톤 AND 직무역량" }, + filters: { years: [2015, 2026], languages: ["English", "Korean"], publicationTypes: ["Article"] }, + protocolHash: "c".repeat(64) + }, + counts, + papers, + inputArtifactIds: ["corpus-frozen-1", "screening-ledger-1"], + generatedAt: "2026-08-06T08:00:00.000Z", + generatorVersion: "0.1.72", + approvals: { databasesExecuted: true, fulltextAvailabilityVerified: true, analysisApproved: true }, + analysisPlan: "structured_document_analysis", + abstract: "본 연구는 해커톤형 집중 프로그램을 직무역량 수요와 교육 공급의 불일치 관점에서 분석하기 위한 재현 가능한 체계적 문서분석 절차를 제안한다." +}); +assert.match(manuscript.sections.find((entry) => entry.id === "methods").content, /검색하였다/); +assert.match(manuscript.sections.find((entry) => entry.id === "results").content, /2편/); +assert.match(manuscript.metaAnalysisReadinessMarkdown, /효과크기 산출 가능성/); +assert(manuscript.provenanceMap.every((entry) => entry.inputArtifactIds.length > 0)); +assert.throws(() => research.buildApprovedManuscript({ + title: "Blocked", authorLines: [], language: "en", keywords: [], protocol: manuscript.protocol, + counts, papers, inputArtifactIds: ["corpus-frozen-1"], generatedAt: "2026-08-06T08:00:00.000Z", generatorVersion: "0.1.72", + approvals: { databasesExecuted: false, fulltextAvailabilityVerified: true, analysisApproved: true }, analysisPlan: "meta_analysis" +}), /unexecuted database/i); + +const retainedOutputDir = process.env.LONGTABLE_MANUSCRIPT_OUTPUT_DIR; +const temp = retainedOutputDir ? resolve(retainedOutputDir) : await mkdtemp(join(tmpdir(), "longtable-manuscript-")); +if (retainedOutputDir) await mkdir(temp, { recursive: true }); +try { + const outputPath = join(temp, "manuscript.docx"); + const rendered = research.renderWordManuscript({ manuscript, profile: apaProfile, outputPath }); + assert.equal(rendered.outputPath, outputPath); + const zip = spawnSync("unzip", ["-t", outputPath], { encoding: "utf8" }); + assert.equal(zip.status, 0, zip.stderr || zip.stdout); + const documentXml = spawnSync("unzip", ["-p", outputPath, "word/document.xml"], { encoding: "utf8" }); + assert.equal(documentXml.status, 0); + assert.match(documentXml.stdout, /해커톤형 집중 프로그램/); + assert.match(documentXml.stdout, /w:hanging="720"/); + const stylesXml = spawnSync("unzip", ["-p", outputPath, "word/styles.xml"], { encoding: "utf8" }); + assert.equal(stylesXml.status, 0); + assert.match(stylesXml.stdout, /w:line="480"/); + const renderedAgain = research.renderWordManuscript({ manuscript, profile: apaProfile, outputPath: join(temp, "manuscript-2.docx") }); + assert.equal(rendered.contentSha256, renderedAgain.contentSha256); +} finally { + if (!retainedOutputDir) await rm(temp, { recursive: true, force: true }); +} + +console.log("manuscript package tests passed"); From 19d36f2b3359f7e93d11296099ac889cdcb50cf9 Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:14:02 +0900 Subject: [PATCH 18/25] test(research): enforce failure security and token budgets --- package.json | 3 +- .../longtable-scholar-research/src/index.ts | 2 + .../src/model-budget.ts | 55 +++++++++ .../longtable-scholar-research/src/retry.ts | 82 +++++++++++++ scripts/audit-research-artifacts.mjs | 113 ++++++++++++++++++ scripts/smoke-research-failures.mjs | 83 +++++++++++++ 6 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 packages/longtable-scholar-research/src/model-budget.ts create mode 100644 packages/longtable-scholar-research/src/retry.ts create mode 100644 scripts/audit-research-artifacts.mjs create mode 100644 scripts/smoke-research-failures.mjs diff --git a/package.json b/package.json index 05e4bd7..13d2ec1 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "smoke:screening": "node scripts/smoke-screening.mjs", "smoke:research-reporting": "node scripts/smoke-research-reporting.mjs", "smoke:manuscript-package": "node scripts/smoke-manuscript-package.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run smoke:browser-recipes && npm run smoke:screening && npm run smoke:research-reporting && npm run smoke:manuscript-package && npm run typecheck", + "smoke:research-failures": "node scripts/smoke-research-failures.mjs", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run smoke:browser-recipes && npm run smoke:screening && npm run smoke:research-reporting && npm run smoke:manuscript-package && npm run smoke:research-failures && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index 13cd973..b325798 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -23,3 +23,5 @@ export * from "./references.js"; export * from "./render-profiles.js"; export * from "./manuscript.js"; export * from "./word-renderer.js"; +export * from "./retry.js"; +export * from "./model-budget.js"; diff --git a/packages/longtable-scholar-research/src/model-budget.ts b/packages/longtable-scholar-research/src/model-budget.ts new file mode 100644 index 0000000..28d546f --- /dev/null +++ b/packages/longtable-scholar-research/src/model-budget.ts @@ -0,0 +1,55 @@ +import { createHash } from "node:crypto"; +import type { InstitutionalResearchStage } from "./workflow-types.js"; + +export interface ModelCallBudget { + readonly stage: InstitutionalResearchStage; + readonly purpose: string; + readonly maximumCalls: number; +} + +export interface ModelCallCacheKeyInput { + readonly artifactSha256: string; + readonly ruleVersion: string; + readonly codebookVersion: string; + readonly promptVersion: string; + readonly modelVersion: string; +} + +export function buildModelCallCacheKey(input: ModelCallCacheKeyInput): string { + if (!/^[a-f0-9]{64}$/i.test(input.artifactSha256)) throw new Error("Model cache input requires an artifact SHA-256 hash."); + return createHash("sha256").update(JSON.stringify({ + artifactSha256: input.artifactSha256.toLowerCase(), + codebookVersion: input.codebookVersion, + modelVersion: input.modelVersion, + promptVersion: input.promptVersion, + ruleVersion: input.ruleVersion + })).digest("hex"); +} + +export function modelCallBudgetForStage( + stage: InstitutionalResearchStage, + purpose = "mechanical_stage", + configuredMaximumCalls?: number +): ModelCallBudget { + if (stage === "PROTOCOL_CHECKPOINT" && purpose === "search_strategy_proposal") { + return { stage, purpose, maximumCalls: 1 }; + } + if (stage === "PILOT" && purpose === "pilot_interpretation") { + return { stage, purpose, maximumCalls: 1 }; + } + if ((stage === "TITLE_ABSTRACT_SCREENING" || stage === "FULLTEXT_SCREENING") && purpose === "configured_screening_batch") { + if (!Number.isInteger(configuredMaximumCalls) || (configuredMaximumCalls ?? 0) < 0) { + throw new Error("A configured screening batch requires a non-negative integer model-call limit."); + } + return { stage, purpose, maximumCalls: configuredMaximumCalls as number }; + } + return { stage, purpose, maximumCalls: 0 }; +} + +export function assertModelCallBudget(budget: ModelCallBudget, observedCalls: number): void { + if (!Number.isInteger(observedCalls) || observedCalls < 0) throw new Error("Observed model calls must be a non-negative integer."); + if (observedCalls > budget.maximumCalls) { + throw new Error(`Model-call budget exceeded for ${budget.stage}/${budget.purpose}: observed ${observedCalls}, allowed ${budget.maximumCalls}.`); + } +} + diff --git a/packages/longtable-scholar-research/src/retry.ts b/packages/longtable-scholar-research/src/retry.ts new file mode 100644 index 0000000..b6146c4 --- /dev/null +++ b/packages/longtable-scholar-research/src/retry.ts @@ -0,0 +1,82 @@ +import { + INSTITUTIONAL_RESEARCH_HARD_STOPS, + INSTITUTIONAL_RESEARCH_RETRYABLE_EVENTS, + type InstitutionalResearchRetryableEvent +} from "./workflow-types.js"; + +export const RESEARCH_FAILURE_INJECTION_MATRIX = [ + "network_timeout", + "delayed_export", + "partial_download", + "corrupt_pdf", + "empty_export", + "count_mismatch", + "placeholder_file", + "interrupted_run", + "missing_doi", + "session_expiry", + "result_cap", + "onedrive_hydration_delay" +] as const; + +export interface RetryPlanInput { + readonly code: string; + readonly maxAttempts: number; + readonly baseDelayMs: number; + readonly maxDelayMs: number; +} + +export interface RetryPlan { + readonly code: InstitutionalResearchRetryableEvent; + readonly maxAttempts: number; + readonly delaysMs: readonly number[]; + readonly requiresResearcherDecision: false; +} + +export interface RetryOutcome { + readonly code: InstitutionalResearchRetryableEvent; + readonly attempts: number; + readonly recovered: boolean; +} + +function positiveInteger(value: number, label: string): void { + if (!Number.isInteger(value) || value < 1) throw new Error(`${label} must be a positive integer.`); +} + +export function buildRetryPlan(input: RetryPlanInput): RetryPlan { + if ((INSTITUTIONAL_RESEARCH_HARD_STOPS as readonly string[]).includes(input.code)) { + throw new Error(`${input.code} is a hard stop and must never be retried automatically.`); + } + if (!(INSTITUTIONAL_RESEARCH_RETRYABLE_EVENTS as readonly string[]).includes(input.code)) { + throw new Error(`Unknown retry event: ${input.code}.`); + } + positiveInteger(input.maxAttempts, "Maximum attempts"); + positiveInteger(input.baseDelayMs, "Base delay"); + positiveInteger(input.maxDelayMs, "Maximum delay"); + if (input.maxDelayMs < input.baseDelayMs) throw new Error("Maximum delay must be at least the base delay."); + const delaysMs = Array.from({ length: Math.max(0, input.maxAttempts - 1) }, (_, index) => + Math.min(input.maxDelayMs, input.baseDelayMs * (2 ** index)) + ); + return { + code: input.code as InstitutionalResearchRetryableEvent, + maxAttempts: input.maxAttempts, + delaysMs, + requiresResearcherDecision: false + }; +} + +export function aggregateRetryAdvisory(outcomes: readonly RetryOutcome[]): { + readonly advisoryCount: 0 | 1; + readonly message: string; +} { + if (outcomes.length === 0) return { advisoryCount: 0, message: "No retryable events were recorded." }; + const counts = new Map(); + for (const outcome of outcomes) counts.set(outcome.code, (counts.get(outcome.code) ?? 0) + 1); + const summary = [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([code, count]) => `${code} × ${count}`).join("; "); + const unrecovered = outcomes.filter((outcome) => !outcome.recovered).length; + return { + advisoryCount: 1, + message: `Retry summary: ${summary}. ${unrecovered} event(s) remained unresolved; see the retry ledger for artifact-level details.` + }; +} + diff --git a/scripts/audit-research-artifacts.mjs b/scripts/audit-research-artifacts.mjs new file mode 100644 index 0000000..3798718 --- /dev/null +++ b/scripts/audit-research-artifacts.mjs @@ -0,0 +1,113 @@ +import { readFile, readdir, stat } from "node:fs/promises"; +import { basename, join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const FORBIDDEN_KEY = /(?:password|passphrase|credential|cookie|session[_-]?token|access[_-]?token|refresh[_-]?token|auth(?:entication|orization)?[_-]?header|mfa|one[_-]?time[_-]?(?:code|password)|captcha)/i; +const ABSOLUTE_USER_PATH = /(?:^|[\s"'])(?:\/Users\/[^/\s"']+|\/home\/[^/\s"']+|[A-Za-z]:\\Users\\[^\\\s"']+)/; +const LICENSED_BYTES = /LICENSED[_ -]FULLTEXT|DO[_ -]NOT[_ -]REDISTRIBUTE|publisher[_ -]licensed[_ -]copy/i; + +function finding(artifactId, kind, location) { + return { artifactId, kind, location }; +} + +function inspectValue(artifactId, value, path, findings) { + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + const prefix = Buffer.from(value).subarray(0, 8192).toString("latin1"); + if (LICENSED_BYTES.test(prefix)) findings.push(finding(artifactId, "licensed_fulltext_bytes", path)); + return; + } + if (Array.isArray(value)) { + value.forEach((entry, index) => inspectValue(artifactId, entry, `${path}[${index}]`, findings)); + return; + } + if (value && typeof value === "object") { + for (const [key, nested] of Object.entries(value)) { + const location = `${path}.${key}`; + if (FORBIDDEN_KEY.test(key)) findings.push(finding(artifactId, "authentication_material_key", location)); + inspectValue(artifactId, nested, location, findings); + } + return; + } + if (typeof value === "string") { + if (ABSOLUTE_USER_PATH.test(value)) findings.push(finding(artifactId, "absolute_user_path", path)); + if (LICENSED_BYTES.test(value)) findings.push(finding(artifactId, "licensed_fulltext_marker", path)); + } +} + +export function auditResearchArtifactObjects(artifacts) { + const findings = []; + for (const artifact of artifacts) inspectValue(artifact.artifactId, artifact.value, "$", findings); + findings.sort((left, right) => left.artifactId.localeCompare(right.artifactId) || left.kind.localeCompare(right.kind) || left.location.localeCompare(right.location)); + return { passed: findings.length === 0, findings }; +} + +async function walk(root) { + const output = []; + let entries; + try { entries = await readdir(root, { withFileTypes: true }); } catch { return output; } + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (["node_modules", ".git", ".worktrees", "dist"].includes(entry.name)) continue; + const path = join(root, entry.name); + if (entry.isDirectory()) output.push(...await walk(path)); + else if (entry.isFile()) output.push(path); + } + return output; +} + +async function auditRoots(repoRoot) { + const candidateRoots = [ + join(repoRoot, ".longtable", "research-projects"), + join(repoRoot, ".longtable", "research-runs"), + join(repoRoot, "packages", "longtable-scholar-research", "fixtures", "institutions"), + join(repoRoot, "packages", "longtable-scholar-research", "fixtures", "browser") + ]; + const artifacts = []; + for (const root of candidateRoots) { + for (const path of await walk(root)) { + const info = await stat(path); + if (info.size > 5 * 1024 * 1024) { + artifacts.push({ artifactId: relative(repoRoot, path), value: "oversized artifact omitted from value scan" }); + continue; + } + const bytes = await readFile(path); + const id = relative(repoRoot, path); + if (/\.(?:json|jsonl)$/i.test(path)) { + const text = bytes.toString("utf8"); + try { + const value = path.endsWith(".jsonl") ? text.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)) : JSON.parse(text); + artifacts.push({ artifactId: id, value }); + } catch { + artifacts.push({ artifactId: id, value: text }); + } + } else if (/\.(?:ya?ml|md|txt|html)$/i.test(path)) { + artifacts.push({ artifactId: id, value: bytes.toString("utf8") }); + } else { + artifacts.push({ artifactId: id, value: bytes }); + } + } + } + return auditResearchArtifactObjects(artifacts); +} + +async function main() { + const args = process.argv.slice(2); + if (args.includes("--fixtures")) { + const clean = auditResearchArtifactObjects([{ artifactId: "fixture-clean", value: { accessBasis: "permitted", localPath: "corpus/paper.json" } }]); + const seeded = auditResearchArtifactObjects([{ artifactId: "fixture-seeded", value: { access_token: "redacted-test-value" } }]); + if (!clean.passed || seeded.passed || JSON.stringify(seeded).includes("redacted-test-value")) throw new Error("Research artifact audit fixture self-test failed."); + console.log("research artifact audit fixtures passed"); + return; + } + const repoIndex = args.indexOf("--repo"); + const repoRoot = resolve(repoIndex >= 0 ? args[repoIndex + 1] ?? "." : "."); + const report = await auditRoots(repoRoot); + if (!report.passed) { + console.error(JSON.stringify(report, null, 2)); + process.exitCode = 1; + return; + } + console.log(`research artifact audit passed for ${basename(repoRoot)}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) await main(); + diff --git a/scripts/smoke-research-failures.mjs b/scripts/smoke-research-failures.mjs new file mode 100644 index 0000000..762d979 --- /dev/null +++ b/scripts/smoke-research-failures.mjs @@ -0,0 +1,83 @@ +import { strict as assert } from "node:assert"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const research = await import(resolve(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); +const auditModule = await import(pathToFileURL(resolve(repoRoot, "scripts", "audit-research-artifacts.mjs"))); + +const retryable = [ + "TRANSIENT_NETWORK_TIMEOUT", + "DELAYED_EXPORT_GENERATION", + "INCOMPLETE_DOWNLOAD", + "TEMPORARY_PAGE_RENDER_FAILURE", + "FILE_PROVIDER_HYDRATION_DELAY" +]; +for (const code of retryable) { + const plan = research.buildRetryPlan({ code, maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 250 }); + assert.deepEqual(plan.delaysMs, [100, 200]); + assert.equal(plan.maxAttempts, 3); + assert.equal(plan.requiresResearcherDecision, false); +} + +for (const code of ["SESSION_EXPIRED", "DATABASE_RESULT_CAP_REACHED", "EXPORT_COUNT_MISMATCH", "DOWNLOAD_PATTERN_CHANGED"]) { + assert.throws(() => research.buildRetryPlan({ code, maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 250 }), /hard stop/i); +} +assert.throws(() => research.buildRetryPlan({ code: "CORRUPT_PDF", maxAttempts: 2, baseDelayMs: 100, maxDelayMs: 200 }), /unknown/i); + +const advisory = research.aggregateRetryAdvisory([ + { code: "DELAYED_EXPORT_GENERATION", attempts: 2, recovered: true }, + { code: "TRANSIENT_NETWORK_TIMEOUT", attempts: 3, recovered: false }, + { code: "DELAYED_EXPORT_GENERATION", attempts: 2, recovered: true } +]); +assert.equal(advisory.advisoryCount, 1); +assert.match(advisory.message, /DELAYED_EXPORT_GENERATION × 2/); +assert.match(advisory.message, /TRANSIENT_NETWORK_TIMEOUT × 1/); + +const mechanicalStages = ["EXPORT_AUDIT", "FULLTEXT_ACQUISITION", "RESEARCHER_REPORT", "MANUSCRIPT_PACKAGE"]; +for (const stage of mechanicalStages) { + const budget = research.modelCallBudgetForStage(stage); + assert.equal(budget.maximumCalls, 0); + assert.doesNotThrow(() => research.assertModelCallBudget(budget, 0)); + assert.throws(() => research.assertModelCallBudget(budget, 1), /model-call budget/i); +} +assert.equal(research.modelCallBudgetForStage("PROTOCOL_CHECKPOINT", "search_strategy_proposal").maximumCalls, 1); +assert.equal(research.modelCallBudgetForStage("PILOT", "pilot_interpretation").maximumCalls, 1); +assert.equal(research.modelCallBudgetForStage("TITLE_ABSTRACT_SCREENING", "configured_screening_batch", 20).maximumCalls, 20); +assert.throws(() => research.modelCallBudgetForStage("TITLE_ABSTRACT_SCREENING", "configured_screening_batch"), /configured screening batch/i); +const cacheInput = { + artifactSha256: "e".repeat(64), ruleVersion: "rules-v1", codebookVersion: "codebook-v1", + promptVersion: "prompt-v1", modelVersion: "model-v1" +}; +assert.equal(research.buildModelCallCacheKey(cacheInput), research.buildModelCallCacheKey(cacheInput)); +assert.notEqual(research.buildModelCallCacheKey(cacheInput), research.buildModelCallCacheKey({ ...cacheInput, promptVersion: "prompt-v2" })); + +const cleanAudit = auditModule.auditResearchArtifactObjects([ + { artifactId: "artifact-clean", value: { accessBasis: "institutional_subscription", localPath: "corpus/frozen.jsonl" } } +]); +assert.equal(cleanAudit.passed, true); + +const seeded = auditModule.auditResearchArtifactObjects([ + { artifactId: "artifact-credential", value: { credential: "should-never-print" } }, + { artifactId: "artifact-cookie", value: { cookie: "also-never-print" } }, + { artifactId: "artifact-token", value: { access_token: "token-never-print" } }, + { artifactId: "artifact-header", value: { authorization_header: "Bearer never-print" } }, + { artifactId: "artifact-path", value: { localPath: "/Users/researcher/private/file.pdf" } }, + { artifactId: "artifact-licensed", value: Buffer.from("%PDF-1.7\nLICENSED_FULLTEXT_DO_NOT_REDISTRIBUTE") } +]); +assert.equal(seeded.passed, false); +assert.deepEqual([...new Set(seeded.findings.map((finding) => finding.artifactId))].sort(), [ + "artifact-cookie", "artifact-credential", "artifact-header", "artifact-licensed", "artifact-path", "artifact-token" +]); +const serialized = JSON.stringify(seeded); +for (const secret of ["should-never-print", "also-never-print", "token-never-print", "Bearer never-print"]) { + assert(!serialized.includes(secret)); +} + +const faultMatrix = [ + "network_timeout", "delayed_export", "partial_download", "corrupt_pdf", "empty_export", "count_mismatch", + "placeholder_file", "interrupted_run", "missing_doi", "session_expiry", "result_cap", "onedrive_hydration_delay" +]; +assert.deepEqual(research.RESEARCH_FAILURE_INJECTION_MATRIX, faultMatrix); + +console.log("research failure, security, and model-budget tests passed"); From 0622c1c764c02d6040e51d5a746e8a9ac555e92e Mon Sep 17 00:00:00 2001 From: HosungYou <136484001+HosungYou@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:21:12 +0900 Subject: [PATCH 19/25] feat(research): complete institutional review workflow --- README.md | 14 ++ Spec.md | 4 + docs/INSTITUTIONAL-RESEARCH-WORKFLOW.md | 143 +++++++++++ package.json | 3 +- .../src/checkpoint-delivery.ts | 41 ++++ .../src/coordinator.ts | 225 ++++++++++++++++++ .../longtable-scholar-research/src/index.ts | 2 + packages/longtable/src/cli.ts | 61 ++++- scripts/e2e-institutional-review.mjs | 173 ++++++++++++++ 9 files changed, 663 insertions(+), 3 deletions(-) create mode 100644 docs/INSTITUTIONAL-RESEARCH-WORKFLOW.md create mode 100644 packages/longtable-scholar-research/src/checkpoint-delivery.ts create mode 100644 packages/longtable-scholar-research/src/coordinator.ts create mode 100644 scripts/e2e-institutional-review.mjs diff --git a/README.md b/README.md index 65093af..817e3d7 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,20 @@ longtable search --query "" longtable panel --prompt "review this measurement plan" --json ``` +## Institutional Research + +`longtable research` is the single surface for reproducible institutional-database work. It provides pilot and protocol checkpoints, immutable academic-database exports, append-only screening decisions, external Research PDF Vault intake, audited reports, and APA 7 or journal-profile Word packages. + +```bash +longtable research init --cwd "" +longtable research pilot --run-id pilot-1 --protocol-reference draft-1 --cwd "" +longtable research freeze --protocol-file protocol.json --cwd "" +longtable research ingest-export --file export.ris --database scopus --format ris --cwd "" +longtable research status --cwd "" +``` + +MCP is the preferred checkpoint transport and numbered text is the recorded fallback. Authentication, MFA, CAPTCHA, terms decisions, and ambiguous methods remain researcher-owned. Bulk production is unavailable until an approved institution profile passes a 5-20 record live smoke gate. See [the institutional research workflow](docs/INSTITUTIONAL-RESEARCH-WORKFLOW.md) for artifact locations, resume rules, reporting-to-manuscript mapping, PDF boundaries, and all commands. + Provider skill installation is explicit: ```bash diff --git a/Spec.md b/Spec.md index fd7a2ff..d5e4253 100644 --- a/Spec.md +++ b/Spec.md @@ -11,6 +11,8 @@ Repository framing and migration boundary are defined in `README.md`, `RELATIONS Active supporting docs are: - `docs/superpowers/specs/2026-08-06-longtable-research-institutional-workflow-design.md` +- `docs/superpowers/plans/2026-08-06-longtable-research-institutional-workflow.md` +- `docs/INSTITUTIONAL-RESEARCH-WORKFLOW.md` - `docs/ARCHITECTURE.md` - `docs/ACTIVE-DECISIONS.md` - `docs/CHECKPOINTING.md` @@ -95,6 +97,8 @@ Current code-level scaffolds now cover: - question runtime contracts now separate shared checkpoint judgment from Claude/Codex-specific presentation surfaces - panel orchestration is now scoped as provider-neutral `InvocationIntent` and `PanelPlan` semantics with provider-specific execution surfaces and sequential fallback - `@longtable/mcp` now exposes `longtable-state` as optional structured transport over `.longtable/` state, not as a replacement source of truth +- `@longtable/scholar-research` now exposes one receipt-backed institutional workflow for protocol freezing, export intake, screening, Research PDF Vault manifests, audited reporting, and declarative Word render profiles +- institutional production eligibility requires profile approval, deterministic replay, a researcher-approved 5-20 record live smoke run, and one or two permitted PDF intakes ## Core Domain Objects diff --git a/docs/INSTITUTIONAL-RESEARCH-WORKFLOW.md b/docs/INSTITUTIONAL-RESEARCH-WORKFLOW.md new file mode 100644 index 0000000..9d04338 --- /dev/null +++ b/docs/INSTITUTIONAL-RESEARCH-WORKFLOW.md @@ -0,0 +1,143 @@ +# Institutional Research Workflow + +This is the researcher-facing operating contract for the integrated `longtable research` surface. It covers reproducible academic-database retrieval, document screening, lawful full-text intake, reporting, and manuscript rendering. It does not automate authentication, decide an ambiguous inclusion rule, or convert a pilot into production without a recorded researcher decision. + +## Method boundary + +Use two related but distinct evidence streams for a demand-supply mismatch study: + +1. **Demand corpus:** procurement notices, commissioned-research topics, policy documents, and employer demand documents. Treat this as a document-analysis population with an explicit sampling frame, inclusion criteria, extraction matrix, pilot coding, agreement/adjudication, and missing-document audit. Do not call this corpus a PRISMA literature search. +2. **Supply and scholarly corpus:** training-course metadata and scholarly records from selected academic databases. The academic search can use the workflow below and can produce review-flow artifacts when its count invariants pass. + +The two streams meet only after each has its own stable unit of analysis and codebook. A conceptual framework may then map demanded competencies and problem types to supplied curricula, learning protocols, and measured competency outcomes. This separation prevents procurement demand from being mistaken for scholarly evidence and prevents course availability from being mistaken for effectiveness. + +## One surface, eight commands + +Initialize the canonical folders before the pilot: + +```bash +longtable research init --cwd "" +longtable research pilot --run-id --protocol-reference draft-1 --cwd "" +``` + +The pilot creates one compact search-strategy checkpoint. MCP elicitation is attempted first. Decline, timeout, unsupported transport, or transport error records the failed attempt and renders the same checkpoint as numbered text; it does not infer an answer. + +After the researcher approves a protocol revision: + +```bash +longtable research freeze --protocol-file protocol-revision.json --cwd "" +longtable research ingest-export --file export.csv --database web-of-science --format csv --cwd "" +longtable research screen --decisions-file screening-decisions.json --cwd "" +longtable research acquire --candidate-path paper.pdf --vault-root "" --paper-id \ + --acquisition-method researcher_manual_upload --access-basis researcher_provided \ + --version accepted-manuscript --project-inclusion included --screening-state fulltext_included \ + --cwd "" +longtable research report --input-file report-input.json --cwd "" +longtable research package --input-file manuscript-input.json --profile-file apa7.json \ + --output-path manuscript/review.docx --cwd "" +``` + +`freeze` writes immutable, numbered revisions. The same revision and content is idempotent; different content cannot overwrite an existing revision number. `ingest-export` stores the original export under its SHA-256, then produces normalized and deduplicated derivatives. Re-ingesting identical bytes does not create a second raw artifact. `screen` appends versioned decisions and ignores an identical decision ID on replay. + +## Mandatory checkpoints and resume rule + +| Event | Immediate action | Resume evidence | +|---|---|---| +| Login, MFA, CAPTCHA, or expired session | Stop at the last safe cursor | Researcher restores access and records a linked DecisionRecord | +| Terms or access basis is unclear | Stop before retrieval | Researcher records the permitted route or excludes the route | +| Query drift, result cap, export mismatch, or changed download pattern | Stop production | Researcher approves a protocol amendment or corrective action | +| Ambiguous screening rule or human-AI disagreement | Preserve both decisions; do not collapse | Adjudication decision linked to the QuestionRecord | +| Missing full-text threshold exceeded | Stop full-text screening | Revised acquisition plan or approved limitation | +| Analysis method changes | Stop synthesis | Approved analysis-method DecisionRecord | +| Corpus freeze is absent | Refuse analysis | Frozen corpus receipt | + +Every stage advances only with a `StageReceipt`. A blocked run resumes only when the DecisionRecord references both the blocking checkpoint key and QuestionRecord ID. Replaying resume from the same blocked state with the same timestamp and decision produces the same state. + +## Canonical project artifacts + +```text +/ + protocol/ + research-question.yaml + search-protocol.yaml + inclusion-exclusion.yaml + database-profiles/ + amendments/protocol-revision-0001.json + data/ + 00_raw-exports/ + 01_normalized/ + 02_deduplicated/ + 03_title-abstract-screening/ + 04_fulltext-screening/ + 05_analysis-ready/ + corpus/ + papers.jsonl + acquisition-ledger.jsonl + pdf-manifest.jsonl + screening-decisions.jsonl + manual-action-queue.csv + audit/ + stage-receipts.jsonl + checkpoints.jsonl + decisions.jsonl + exclusions.jsonl + deviations.jsonl + failures.jsonl + reports/ + manuscript/ + analysis/ + references/ +``` + +The Research PDF Vault must be outside the project. The project retains only a manifest reference, SHA-256, access basis, acquisition method, version, and screening state. Placeholder, partial, corrupt, symbolic-link, or token-bearing PDF intake is rejected. + +## Final researcher report and paper mapping + +The report command generates a researcher audit report, count table, deterministic review-flow SVG, systematic-review Methods/Results text, and provenance records. + +| Research artifact | Direct manuscript use | +|---|---| +| Frozen protocol and exact database queries | Methods: information sources, search strategy, eligibility criteria | +| Database yields and raw-export hashes | Methods/Results: search dates, yields, export reconciliation | +| Screening ledger and exclusion reasons | Methods: selection process; Results: study selection and exclusions | +| PDF manifest and missing-text audit | Methods: retrieval routes; Results/Limitations: reports not retrieved | +| Count-invariant report and review-flow SVG | Results table and study-flow figure | +| Deviation and DecisionRecord ledger | Protocol amendments and deviations | +| Analysis-readiness table | Analysis plan, missing statistics, dependency and author-contact needs | +| Paragraph/table provenance map | Internal audit before submission | + +Planned work renders future-tense protocol language. Completed work renders past-tense Methods and observed Results. Rendering fails when count equations do not balance or when database execution, full-text verification, or analysis approval is absent. + +The review-flow output is an internal deterministic representation, not a claim that every document corpus is a systematic review. Final reporting should be checked against the [PRISMA 2020 checklist and flow materials](https://www.prisma-statement.org/prisma-2020) and, for literature-search reporting, the [PRISMA-S extension](https://doi.org/10.1186/s13643-020-01542-z). + +## Word profiles + +The included `apa7-memory` profile fixes a working-manuscript layout: US Letter, one-inch margins, Times New Roman 12 pt, double spacing, top-right page numbers, title page, 0.5-inch first-line indents, and 0.5-inch hanging reference indents. It is a declared working profile, not a substitute for a named journal's current author instructions. + +The Korean profile is explicitly `provisional_until_journal_rules_verified`. A named target-journal profile should be approved only after recording its aims and scope, author instructions, article type, recent reference-paper pattern, table/figure conventions, and template hash. User templates are accepted only with a matching SHA-256. + +## Live institution smoke gate + +Production eligibility requires a researcher-owned login, an approved institution profile, deterministic recipe replay, and a live smoke run: + +```bash +longtable research live-smoke --run-id --record-count 5 --pdf-count 1 \ + --researcher-approved --profile-approved --calibrated --replay-passed --cwd "" +``` + +The gate accepts 5-20 metadata records and one or two permitted PDFs. Anything larger is not a smoke run. The receipt records the counts and eligibility decision; it never stores credentials, cookies, session tokens, MFA material, or authentication headers. + +## Researcher closeout + +Before manuscript submission, the researcher receives and reviews: + +1. frozen protocol revision and amendments; +2. database-by-database yield and export reconciliation; +3. unresolved issues, recovered failures, exclusions, and deviations; +4. corpus and PDF-manifest invariants; +5. analysis-readiness and required-action list; +6. manuscript profile status and unresolved citation fields; +7. artifact provenance map and security-audit result. + +This closeout is the final checkpoint. The automation prepares the evidence package; the researcher remains responsible for methodological interpretation, journal fit, and submission. + diff --git a/package.json b/package.json index 13d2ec1..3cb0653 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "smoke:research-reporting": "node scripts/smoke-research-reporting.mjs", "smoke:manuscript-package": "node scripts/smoke-manuscript-package.mjs", "smoke:research-failures": "node scripts/smoke-research-failures.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run smoke:browser-recipes && npm run smoke:screening && npm run smoke:research-reporting && npm run smoke:manuscript-package && npm run smoke:research-failures && npm run typecheck", + "e2e:institutional-review": "node scripts/e2e-institutional-review.mjs", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run smoke:institutional-workflow && npm run smoke:institutional-checkpoints && npm run smoke:export-parsers && npm run smoke:corpus-pipeline && npm run smoke:pdf-vault && npm run smoke:institution-adapters && npm run smoke:browser-recipes && npm run smoke:screening && npm run smoke:research-reporting && npm run smoke:manuscript-package && npm run smoke:research-failures && npm run e2e:institutional-review && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, diff --git a/packages/longtable-scholar-research/src/checkpoint-delivery.ts b/packages/longtable-scholar-research/src/checkpoint-delivery.ts new file mode 100644 index 0000000..c046922 --- /dev/null +++ b/packages/longtable-scholar-research/src/checkpoint-delivery.ts @@ -0,0 +1,41 @@ +import type { QuestionRecord, QuestionTransportStatus } from "@longtable/core"; + +export interface CheckpointDeliveryAttempt { + readonly surface: "mcp_elicitation" | "numbered"; + readonly status: QuestionTransportStatus; + readonly attemptedAt: string; + readonly message?: string; +} + +export interface CheckpointDeliveryRecord { + readonly questionRecordId: string; + readonly checkpointKey: string; + readonly attempts: readonly CheckpointDeliveryAttempt[]; +} + +export function deliverCheckpointWithFallback( + question: QuestionRecord, + input: { + readonly status: "declined" | "unsupported" | "timeout" | "error"; + readonly message: string; + readonly attemptedAt: string; + readonly fallbackAt: string; + } +): { readonly question: QuestionRecord; readonly delivery: CheckpointDeliveryRecord } { + if (question.status !== "pending" || !question.prompt.checkpointKey) { + throw new Error("Only a pending checkpoint with a stable key can use transport fallback."); + } + const attempts: CheckpointDeliveryAttempt[] = [ + { surface: "mcp_elicitation", status: input.status, attemptedAt: input.attemptedAt, message: input.message }, + { surface: "numbered", status: "fallback_rendered", attemptedAt: input.fallbackAt, message: "MCP did not yield a decision; the same checkpoint was rendered as numbered text." } + ]; + return { + question: { + ...question, + updatedAt: input.fallbackAt, + transportStatus: { surface: "numbered", status: "fallback_rendered", updatedAt: input.fallbackAt, message: attempts[1].message } + }, + delivery: { questionRecordId: question.id, checkpointKey: question.prompt.checkpointKey, attempts } + }; +} + diff --git a/packages/longtable-scholar-research/src/coordinator.ts b/packages/longtable-scholar-research/src/coordinator.ts new file mode 100644 index 0000000..bca4428 --- /dev/null +++ b/packages/longtable-scholar-research/src/coordinator.ts @@ -0,0 +1,225 @@ +import { constants } from "node:fs"; +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, extname, join, resolve } from "node:path"; +import { buildSearchStrategyCheckpoint } from "./checkpoints.js"; +import { createResearchRun, createStageReceipt } from "./workflow-state.js"; +import { createProtocolRevision } from "./workflow-types.js"; +import { + appendJsonlRecord, + buildResearchProjectLayout, + readJsonlRecords, + writeFrozenProtocolRevision, + writeResearchProjectScaffold +} from "./project-store.js"; +import { parseExportArtifact, type ExportFormat } from "./export-parsers.js"; +import { deduplicateBibliographicRecords, normalizeBibliographicRecords } from "./corpus.js"; +import { createScreeningDecision, type ScreeningDecisionInput } from "./screening.js"; +import { intakeResearchPdf, type PdfAccessBasis, type PdfAcquisitionMethod } from "./pdf-vault.js"; +import { renderResearchOutputs, type ResearchReportInput } from "./reporting.js"; +import { buildApprovedManuscript, type BuildApprovedManuscriptInput } from "./manuscript.js"; +import { validateRenderProfile } from "./render-profiles.js"; +import { renderWordManuscript } from "./word-renderer.js"; + +export type InstitutionalResearchCommand = "pilot" | "freeze" | "ingest-export" | "screen" | "acquire" | "report" | "package" | "live-smoke"; +export const INSTITUTIONAL_RESEARCH_COMMANDS: readonly InstitutionalResearchCommand[] = [ + "pilot", "freeze", "ingest-export", "screen", "acquire", "report", "package", "live-smoke" +]; +export type InstitutionalResearchCommandArgs = Readonly>; + +function text(args: InstitutionalResearchCommandArgs, key: string): string { + const value = args[key]; + if (typeof value !== "string" || !value.trim()) throw new Error(`Research command requires --${key}.`); + return value.trim(); +} + +function optionalText(args: InstitutionalResearchCommandArgs, key: string): string | undefined { + const value = args[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +async function jsonFile(path: string): Promise { + return JSON.parse(await readFile(resolve(path), "utf8")) as T; +} + +async function writeJson(path: string, value: unknown): Promise { + await mkdir(resolve(path, ".."), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function projectRoot(args: InstitutionalResearchCommandArgs): string { + return resolve(optionalText(args, "cwd") ?? process.cwd()); +} + +async function pilot(args: InstitutionalResearchCommandArgs) { + const root = projectRoot(args); + const layout = await writeResearchProjectScaffold(root); + const createdAt = optionalText(args, "createdAt") ?? new Date().toISOString(); + const runId = text(args, "runId"); + const protocolReference = optionalText(args, "protocolReference") ?? "draft-1"; + const run = createResearchRun({ id: runId, createdAt }); + const checkpoint = buildSearchStrategyCheckpoint({ + runId, + protocolReference, + createdAt, + reviewType: "systematic document analysis", + objective: "Pilot the frozen demand-supply mismatch review frame before production.", + databaseGroups: { + core: ["web-of-science", "scopus"], + domain: ["ERIC", "RISS"], + complementary: [], + normalization: ["Crossref"], + fulltextResolution: ["institutional resolver", "Research PDF Vault"] + }, + filters: { languages: ["Korean", "English"], publicationTypes: ["Article", "Conference Paper"] }, + recallPrecisionPosture: "recall_first", + accessLimitations: ["Researcher owns login, MFA, CAPTCHA, and terms decisions."] + }); + await appendJsonlRecord(layout.audit.checkpoints, checkpoint); + await writeJson(join(layout.researchRuns, `${runId}.json`), run); + return { surface: "longtable-research", run, checkpoint, layout }; +} + +async function freeze(args: InstitutionalResearchCommandArgs) { + const root = projectRoot(args); + const layout = await writeResearchProjectScaffold(root); + const protocol = createProtocolRevision(await jsonFile(text(args, "protocolFile"))); + const path = await writeFrozenProtocolRevision(layout, protocol); + return { protocol, path }; +} + +async function immutableRawExport(path: string, target: string): Promise { + await mkdir(resolve(target, ".."), { recursive: true }); + try { + await copyFile(path, target, constants.COPYFILE_EXCL); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const [source, existing] = await Promise.all([readFile(path), readFile(target)]); + if (!source.equals(existing)) throw new Error(`Immutable raw-export collision at ${target}.`); + return false; + } +} + +async function ingestExport(args: InstitutionalResearchCommandArgs) { + const root = projectRoot(args); + const layout = await writeResearchProjectScaffold(root); + const path = resolve(text(args, "file")); + const database = text(args, "database"); + const format = text(args, "format") as ExportFormat; + const content = await readFile(path); + const parsed = parseExportArtifact({ filename: basename(path), format, database, content }); + if (parsed.records.length === 0) throw new Error(`Export intake produced no records: ${parsed.rejections.map((entry) => entry.reason).join(", ")}.`); + const extension = extname(path) || `.${format}`; + const rawPath = join(layout.data.rawExports, `${parsed.artifactSha256}${extension}`); + const rawArtifactCreated = await immutableRawExport(path, rawPath); + const normalized = normalizeBibliographicRecords(parsed.records); + const corpus = deduplicateBibliographicRecords(normalized); + await writeJson(join(layout.data.normalized, `${parsed.artifactSha256}.json`), normalized); + await writeJson(join(layout.data.deduplicated, `${parsed.artifactSha256}.json`), corpus); + await writeFile(layout.corpus.papers, corpus.papers.map((paper) => JSON.stringify(paper)).join("\n") + "\n", "utf8"); + return { parsed, corpus, rawPath, rawArtifactCreated }; +} + +async function screen(args: InstitutionalResearchCommandArgs) { + const root = projectRoot(args); + const layout = await writeResearchProjectScaffold(root); + const inputs = await jsonFile(text(args, "decisionsFile")); + const existing = await readJsonlRecords(layout.corpus.screeningDecisions) as unknown as { id: string }[]; + const ids = new Set(existing.map((entry) => entry.id)); + let appended = 0; + for (const input of inputs) { + const decision = createScreeningDecision(input); + if (ids.has(decision.id)) continue; + await appendJsonlRecord(layout.corpus.screeningDecisions, decision); + ids.add(decision.id); + appended += 1; + } + return { appended, total: ids.size }; +} + +async function acquire(args: InstitutionalResearchCommandArgs) { + const root = projectRoot(args); + const layout = await writeResearchProjectScaffold(root); + return intakeResearchPdf({ + projectRoot: root, + vaultRoot: text(args, "vaultRoot"), + projectManifestPath: layout.corpus.pdfManifest, + candidatePath: text(args, "candidatePath"), + paperId: text(args, "paperId"), + acquisitionMethod: text(args, "acquisitionMethod") as PdfAcquisitionMethod, + accessBasis: text(args, "accessBasis") as PdfAccessBasis, + version: text(args, "version"), + projectInclusion: text(args, "projectInclusion"), + screeningState: text(args, "screeningState"), + ...(optionalText(args, "sourceUrl") ? { sourceUrl: optionalText(args, "sourceUrl") } : {}), + ...(optionalText(args, "acquiredAt") ? { acquiredAt: optionalText(args, "acquiredAt") } : {}) + }); +} + +async function report(args: InstitutionalResearchCommandArgs) { + const root = projectRoot(args); + const layout = await writeResearchProjectScaffold(root); + const input = await jsonFile(text(args, "inputFile")); + const outputs = renderResearchOutputs(input); + await Promise.all([ + writeFile(join(layout.reports, "researcher-report.md"), outputs.researcherReportMarkdown, "utf8"), + writeFile(join(layout.reports, "review-flow-counts.md"), outputs.prismaCountTableMarkdown, "utf8"), + writeFile(join(layout.reports, "review-flow.svg"), outputs.prismaFlowSvg, "utf8"), + writeFile(join(layout.reports, "systematic-review.md"), outputs.systematicReviewMarkdown, "utf8"), + writeJson(join(layout.reports, "artifact-provenance.json"), outputs.artifactProvenance) + ]); + return { outputs, reportsDirectory: layout.reports }; +} + +async function packageManuscript(args: InstitutionalResearchCommandArgs) { + const root = projectRoot(args); + const layout = await writeResearchProjectScaffold(root); + const input = await jsonFile(text(args, "inputFile")); + const profile = validateRenderProfile(await jsonFile(text(args, "profileFile"))); + const manuscript = buildApprovedManuscript(input); + const outputPath = resolve(optionalText(args, "outputPath") ?? join(layout.manuscript, "manuscript.docx")); + const rendered = renderWordManuscript({ manuscript, profile, outputPath }); + await writeJson(join(layout.manuscript, `${basename(outputPath, extname(outputPath))}.provenance.json`), manuscript.provenanceMap); + return { manuscript, rendered }; +} + +export function validateLiveSmokeGate(input: { readonly recordCount: number; readonly pdfCount: number; readonly researcherApproved: boolean }): void { + if (!Number.isInteger(input.recordCount) || input.recordCount < 5 || input.recordCount > 20) throw new Error("Live smoke requires 5–20 metadata records."); + if (!Number.isInteger(input.pdfCount) || input.pdfCount < 1 || input.pdfCount > 2) throw new Error("Live smoke requires one or two permitted PDFs."); + if (!input.researcherApproved) throw new Error("Live smoke requires explicit researcher approval."); +} + +async function liveSmoke(args: InstitutionalResearchCommandArgs) { + const root = projectRoot(args); + const layout = await writeResearchProjectScaffold(root); + const recordCount = Number(text(args, "recordCount")); + const pdfCount = Number(text(args, "pdfCount")); + const researcherApproved = args.researcherApproved === true; + validateLiveSmokeGate({ recordCount, pdfCount, researcherApproved }); + const productionEligible = researcherApproved && args.profileApproved === true && args.calibrated === true && args.replayPassed === true; + const completedAt = optionalText(args, "completedAt") ?? new Date().toISOString(); + const receipt = createStageReceipt({ + id: `live-smoke-${text(args, "runId")}`, + runId: text(args, "runId"), + stage: "PILOT", + protocolRevisionId: "institution-profile-calibration", + inputArtifactIds: [], + outputArtifactIds: [`metadata:${recordCount}`, `permitted-pdfs:${pdfCount}`, `production-eligible:${productionEligible}`], + cursor: `live-smoke:${completedAt}`, + createdAt: completedAt + }); + await appendJsonlRecord(layout.audit.stageReceipts, receipt); + return { productionEligible, receipt }; +} + +export async function executeInstitutionalResearchCommand(command: InstitutionalResearchCommand, args: InstitutionalResearchCommandArgs): Promise { + if (command === "pilot") return pilot(args); + if (command === "freeze") return freeze(args); + if (command === "ingest-export") return ingestExport(args); + if (command === "screen") return screen(args); + if (command === "acquire") return acquire(args); + if (command === "report") return report(args); + if (command === "package") return packageManuscript(args); + if (command === "live-smoke") return liveSmoke(args); + throw new Error(`Unknown institutional research command: ${command}`); +} diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index b325798..8c4b272 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -25,3 +25,5 @@ export * from "./manuscript.js"; export * from "./word-renderer.js"; export * from "./retry.js"; export * from "./model-budget.js"; +export * from "./checkpoint-delivery.js"; +export * from "./coordinator.js"; diff --git a/packages/longtable/src/cli.ts b/packages/longtable/src/cli.ts index e99f4c8..5c1166c 100644 --- a/packages/longtable/src/cli.ts +++ b/packages/longtable/src/cli.ts @@ -38,6 +38,8 @@ import { runResearchSearch, SEARCH_SOURCES, buildResearchProjectLayout, + executeInstitutionalResearchCommand, + INSTITUTIONAL_RESEARCH_COMMANDS, readLatestProtocolRevision, writeResearchProjectScaffold, writeScholarResearchRunScaffold, @@ -418,6 +420,14 @@ function usage(): string { " longtable research doctor [--json]", " longtable research init [--cwd ] [--json]", " longtable research status [--cwd ] [--json]", + " longtable research pilot --run-id [--protocol-reference ] [--cwd ] [--json]", + " longtable research freeze --protocol-file [--cwd ] [--json]", + " longtable research ingest-export --file --database --format csv|ris|nbib|json|spreadsheet_xml [--cwd ] [--json]", + " longtable research screen --decisions-file [--cwd ] [--json]", + " longtable research acquire --candidate-path --vault-root --paper-id --acquisition-method --access-basis --version