Spaces:
Runtime error
Runtime error
Abhishek Codex commited on
Commit ·
f9a9b47
1
Parent(s): 361c5ee
Add all folders and files
Browse filesCo-authored-by: Codex <noreply@openai.com>
This view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +19 -0
- .gitignore +18 -0
- CHANGELOG.md +12 -0
- Dockerfile +23 -0
- FIELD_NOTES.md +8 -0
- README.md +161 -14
- app.py +43 -5
- app_kit/__init__.py +23 -0
- app_kit/__main__.py +3 -0
- app_kit/care_circle.py +192 -0
- app_kit/config.py +45 -0
- app_kit/demo_pack.py +44 -0
- app_kit/demo_packs.py +94 -0
- app_kit/embedding.py +49 -0
- app_kit/eval.py +267 -0
- app_kit/eval_runner.py +134 -0
- app_kit/logging_utils.py +31 -0
- app_kit/model_registry.py +57 -0
- app_kit/model_runtime.py +256 -0
- app_kit/project.py +248 -0
- app_kit/server.py +70 -0
- app_kit/sponsor_policy.py +280 -0
- app_kit/storage.py +152 -0
- app_kit/tracing.py +118 -0
- assets/theme.css +235 -0
- configs/model_registry.yaml +59 -0
- configs/sponsor_model_policy.yaml +17 -0
- data/README.md +9 -0
- data/demo_packs/p1_elder_paperwork/sample_appointment_notice/README.md +1 -0
- data/demo_packs/p1_elder_paperwork/sample_appointment_notice/inputs/note.txt +1 -0
- data/demo_packs/p1_elder_paperwork/sample_appointment_notice/manifest.json +18 -0
- data/demo_packs/p1_elder_paperwork/sample_benefits_renewal_png/README.md +1 -0
- data/demo_packs/p1_elder_paperwork/sample_benefits_renewal_png/manifest.json +18 -0
- data/demo_packs/p1_elder_paperwork/sample_clinic_reminder_txt/README.md +1 -0
- data/demo_packs/p1_elder_paperwork/sample_clinic_reminder_txt/inputs/reminder.txt +1 -0
- data/demo_packs/p1_elder_paperwork/sample_clinic_reminder_txt/manifest.json +18 -0
- data/demo_packs/p1_elder_paperwork/sample_final_notice_pdf/README.md +1 -0
- data/demo_packs/p1_elder_paperwork/sample_final_notice_pdf/inputs/notice.pdf +32 -0
- data/demo_packs/p1_elder_paperwork/sample_final_notice_pdf/manifest.json +18 -0
- data/demo_packs/p1_elder_paperwork/sample_insurance_letter_png/README.md +1 -0
- data/demo_packs/p1_elder_paperwork/sample_insurance_letter_png/manifest.json +18 -0
- data/demo_packs/p1_elder_paperwork/sample_lab_reminder_txt/README.md +1 -0
- data/demo_packs/p1_elder_paperwork/sample_lab_reminder_txt/inputs/lab_reminder.txt +1 -0
- data/demo_packs/p1_elder_paperwork/sample_lab_reminder_txt/manifest.json +18 -0
- data/demo_packs/p1_elder_paperwork/sample_medication_change_pdf/README.md +1 -0
- data/demo_packs/p1_elder_paperwork/sample_medication_change_pdf/inputs/medication_change.pdf +32 -0
- data/demo_packs/p1_elder_paperwork/sample_medication_change_pdf/manifest.json +18 -0
- data/demo_packs/p1_elder_paperwork/sample_urgent_notice/README.md +1 -0
- data/demo_packs/p1_elder_paperwork/sample_urgent_notice/inputs/note.txt +1 -0
- data/demo_packs/p1_elder_paperwork/sample_urgent_notice/manifest.json +18 -0
.dockerignore
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.py[cod]
|
| 5 |
+
.pytest_cache/
|
| 6 |
+
.mypy_cache/
|
| 7 |
+
.ruff_cache/
|
| 8 |
+
.venv/
|
| 9 |
+
venv/
|
| 10 |
+
env/
|
| 11 |
+
artifacts/
|
| 12 |
+
logs/
|
| 13 |
+
models/*
|
| 14 |
+
!models/all-MiniLM-L6-v2
|
| 15 |
+
!models/NVIDIA-Nemotron-Parse-v1.1
|
| 16 |
+
!models/cohere-transcribe-03-2026
|
| 17 |
+
*.sqlite3
|
| 18 |
+
*.db
|
| 19 |
+
*.log
|
.gitignore
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.Python
|
| 7 |
+
|
| 8 |
+
# Local artifacts / caches
|
| 9 |
+
models/
|
| 10 |
+
data/artifacts/
|
| 11 |
+
artifacts/
|
| 12 |
+
*.sqlite3
|
| 13 |
+
*.db
|
| 14 |
+
|
| 15 |
+
# OS/editor
|
| 16 |
+
.DS_Store
|
| 17 |
+
.vscode/
|
| 18 |
+
.idea/
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
## Unreleased
|
| 4 |
+
|
| 5 |
+
### Added
|
| 6 |
+
- Added `scripts/share_traces_to_hf_dataset.py`, canonical trace payload fields (`timestamp`, `inputs`, `parsed_outputs`, `model_name`), and offline JSONL/metadata materialization for sharing trace artifacts.
|
| 7 |
+
- Added a P1 standalone-repo cleanup pass to remove cross-project sqlite leftovers and cross-project landing-page references.
|
| 8 |
+
- Added a repo data inventory and README attribution links.
|
| 9 |
+
|
| 10 |
+
### Changed
|
| 11 |
+
- Updated trace artifact generation to expose share-friendly schema fields for dataset export and verification.
|
| 12 |
+
- Lazy-imported Gradio from the P1 app entrypoint so test and eval imports do not require the UI dependency.
|
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim AS llama-builder
|
| 2 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 3 |
+
WORKDIR /opt/llama.cpp
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends build-essential cmake git pkg-config libcurl4-openssl-dev && rm -rf /var/lib/apt/lists/*
|
| 5 |
+
RUN git clone --depth 1 https://github.com/ggml-org/llama.cpp.git .
|
| 6 |
+
RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF -DLLAMA_BUILD_TESTS=OFF && cmake --build build -j$(nproc)
|
| 7 |
+
|
| 8 |
+
FROM python:3.11-slim AS runtime
|
| 9 |
+
ENV DEBIAN_FRONTEND=noninteractive PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PORT=7860 GRADIO_SERVER_NAME=0.0.0.0
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libcurl4 libgomp1 libstdc++6 && rm -rf /var/lib/apt/lists/*
|
| 12 |
+
COPY requirements.txt ./requirements.txt
|
| 13 |
+
RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel && python -m pip install --no-cache-dir -r requirements.txt
|
| 14 |
+
COPY . .
|
| 15 |
+
COPY --from=llama-builder /opt/llama.cpp/build/bin/llama-server /usr/local/bin/llama-server
|
| 16 |
+
COPY --from=llama-builder /opt/llama.cpp/build/bin/llama-cli /usr/local/bin/llama-cli
|
| 17 |
+
|
| 18 |
+
ARG HF_TOKEN
|
| 19 |
+
ENV HF_TOKEN=${HF_TOKEN}
|
| 20 |
+
RUN pip install huggingface_hub hf_transfer
|
| 21 |
+
RUN python scripts/download_gguf.py openbmb/MiniCPM5-1B-GGUF MiniCPM5-1B-Q4_K_M.gguf models/MiniCPM5-1B-Q4_K_M.gguf
|
| 22 |
+
EXPOSE 7860 8080
|
| 23 |
+
CMD ["python", "app.py"]
|
FIELD_NOTES.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Elder Paperwork Co-Pilot demonstrates a well-structured offline-first AI assistant for medical paperwork summarization.
|
| 2 |
+
Key observations:
|
| 3 |
+
- Clean separation of concerns with dedicated directories for apps, models, data, configs, and scripts
|
| 4 |
+
- Comprehensive documentation including README, FIELD_NOTES.md, and verification reports
|
| 5 |
+
- Thoughtful attention to offline operation and data privacy (no PII/PHI in demo packs)
|
| 6 |
+
- Clear pathways for local execution via Python, virtualenv, or Docker
|
| 7 |
+
- Strong emphasis on reproducible verification through structured test suites and eval runners
|
| 8 |
+
The project successfully implements its goal of providing an offline AI assistant for eldercare paperwork, with room for future enhancement through live model integration and performance benchmarking.
|
README.md
CHANGED
|
@@ -1,14 +1,161 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# P1 Elder Paperwork Co-Pilot
|
| 2 |
+
|
| 3 |
+
> An offline AI assistant that securely summarizes complex medical paperwork.
|
| 4 |
+
|
| 5 |
+
Standalone Hugging Face Space repo for the P1 elder-paperwork demo.
|
| 6 |
+
|
| 7 |
+
What this repo contains:
|
| 8 |
+
- the split app entrypoint for this repo only
|
| 9 |
+
- the shared helper modules needed by the app and its eval runner
|
| 10 |
+
- only the demo packs that belong to this split repo
|
| 11 |
+
|
| 12 |
+
## Local run
|
| 13 |
+
|
| 14 |
+
From the repo root:
|
| 15 |
+
|
| 16 |
+
```bash
|
| 17 |
+
python app.py
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
If you prefer an isolated environment:
|
| 21 |
+
|
| 22 |
+
```bash
|
| 23 |
+
python -m venv .venv
|
| 24 |
+
source .venv/bin/activate
|
| 25 |
+
pip install -r requirements.txt
|
| 26 |
+
python app.py
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
The app listens on PORT when set; otherwise `app.py` picks an available local port for local runs.
|
| 30 |
+
|
| 31 |
+
Trace artifacts are written on every demo-pack load or eval run. Use the Load sample data button in the UI or the eval runner JSON `trace_path` field to find the file under `data/artifacts/<project>/traces/`.
|
| 32 |
+
|
| 33 |
+
## Off-brand UI
|
| 34 |
+
|
| 35 |
+
Custom styling lives in `assets/theme.css`.
|
| 36 |
+
Edit that file to tune the accessible high-contrast palette, spacing, and typography.
|
| 37 |
+
The app loads it at launch via Gradio `css_paths`.
|
| 38 |
+
|
| 39 |
+
## Llama Champion smoke
|
| 40 |
+
|
| 41 |
+
The main app stays on its normal offline-first path; the badge is satisfied by a dedicated local GGUF smoke that exercises `llama-cpp-python` end-to-end and writes a small verification artifact.
|
| 42 |
+
|
| 43 |
+
P1 uses `openbmb/MiniCPM5-1B-GGUF` (`MiniCPM5-1B-Q4_K_M.gguf`) as the preferred local GGUF because it matches the registry's MiniCPM-5-1B family.
|
| 44 |
+
|
| 45 |
+
Install dependencies with your normal venv flow; `requirements.txt` already points pip at the CPU wheel index for `llama-cpp-python==0.3.28`.
|
| 46 |
+
|
| 47 |
+
Download the model into `models/`:
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
mkdir -p models
|
| 51 |
+
huggingface-cli download openbmb/MiniCPM5-1B-GGUF MiniCPM5-1B-Q4_K_M.gguf --local-dir models
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Direct smoke from the repo root:
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
LLAMA_CHAMPION_MODEL=models/MiniCPM5-1B-Q4_K_M.gguf python scripts/llama_champion_smoke.py --artifact-path artifacts/verification/$(date +%F)/llama_champion_smoke.json
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
The script writes `artifacts/verification/<YYYY-MM-DD>/llama_champion_smoke.json` by default if you omit `--artifact-path`.
|
| 61 |
+
|
| 62 |
+
Pytest wrapper:
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
LLAMA_CHAMPION_MODEL=models/MiniCPM5-1B-Q4_K_M.gguf .venv/bin/python -m pytest -q tests/test_llama_champion_smoke.py
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
If the pytest env does not already have `llama_cpp`, set `LLAMA_CHAMPION_PYTHON` to the interpreter that does.
|
| 69 |
+
|
| 70 |
+
## Docker
|
| 71 |
+
|
| 72 |
+
Build the image:
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
docker build -t all4-p1 .
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
Run the app container:
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
docker run --rm -p 7860:7860 all4-p1
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
Optional: run the bundled llama.cpp server from the same image with the same GGUF used above:
|
| 85 |
+
|
| 86 |
+
```bash
|
| 87 |
+
docker run --rm -p 8080:8080 -v "$PWD/models:/models" --entrypoint llama-server all4-p1 --model /models/MiniCPM5-1B-Q4_K_M.gguf --host 0.0.0.0 --port 8080
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
Notes:
|
| 91 |
+
- The image is CPU-only and multi-stage; it builds llama.cpp in a builder stage and keeps the runtime stage lean.
|
| 92 |
+
- `.venv/` is ignored by the Docker build context, so local virtualenvs do not get baked into the image.
|
| 93 |
+
- The app and llama-server share the same image but are launched separately.
|
| 94 |
+
|
| 95 |
+
## Offline verification
|
| 96 |
+
|
| 97 |
+
Run the bundled offline smoke check from the repo root:
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
bash scripts/offline_smoke.sh
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
CI-friendly pytest wrapper:
|
| 104 |
+
|
| 105 |
+
```bash
|
| 106 |
+
python -m pytest -q tests/test_offline_smoke.py
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
Docker variant with outbound networking disabled:
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
docker run --rm --network none -v "$PWD:/repo" -w /repo all4-p1 bash scripts/offline_smoke.sh
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
The smoke check loads a bundled demo pack, blocks socket/HTTP client creation, and fails if any runtime code tries to reach the network.
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
## Sponsor model policy gate
|
| 119 |
+
|
| 120 |
+
Run the repo-local sponsor gate without Docker:
|
| 121 |
+
|
| 122 |
+
```bash
|
| 123 |
+
python scripts/check_sponsor_model_policy.py
|
| 124 |
+
pytest -q tests/test_sponsor_model_policy.py
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
The gate checks that the registry matches the four planned P1 sponsor components before any packaging or Docker verification step.
|
| 128 |
+
|
| 129 |
+
## Field notes
|
| 130 |
+
|
| 131 |
+
See [FIELD_NOTES.md](FIELD_NOTES.md) for the badge artifact, evidence notes, and next steps.
|
| 132 |
+
|
| 133 |
+
## Sharing traces
|
| 134 |
+
|
| 135 |
+
Use `python scripts/share_traces_to_hf_dataset.py <traces-dir>` to materialize a deterministic JSONL + metadata bundle under `artifacts/verification/<YYYY-MM-DD>/sharing_is_caring/all4-p1-elder-paperwork/`.
|
| 136 |
+
|
| 137 |
+
- The default mode is local-only; pass `--push` plus `--repo-id` and `HF_TOKEN` to publish a Hugging Face Dataset bundle.
|
| 138 |
+
- `--dry-run` forces offline materialization even when `--push` is present.
|
| 139 |
+
- See `CHANGELOG.md` for the latest trace-sharing notes.
|
| 140 |
+
## Submission assets
|
| 141 |
+
|
| 142 |
+
Fill these TODO fields before final submission; they are placeholders only and do not imply the assets already exist.
|
| 143 |
+
|
| 144 |
+
- [ ] TODO Hugging Face Space URL (build-small org): `<SPACE_URL>`
|
| 145 |
+
- [ ] TODO Public GitHub repo URL: `<REPO_URL>`
|
| 146 |
+
- [ ] TODO Demo video URL: `<VIDEO_URL>`
|
| 147 |
+
- [ ] TODO Social post URL: `<SOCIAL_POST_URL>`
|
| 148 |
+
- [ ] TODO Concise disclaimer: synthetic/repo-authored demo packs only; no PII/PHI.
|
| 149 |
+
- [ ] TODO Sponsor model attribution list:
|
| 150 |
+
- OpenBMB MiniCPM-V 4.6: `openbmb/MiniCPM-V-4_6` for `ocr_vlm`
|
| 151 |
+
- OpenBMB MiniCPM-5 1B: `openbmb/MiniCPM-5-1B` for `triage_llm`
|
| 152 |
+
- NVIDIA NeMoTRON-PARS: `nvidia/NeMoTRON-PARS` for `table_parser`
|
| 153 |
+
- CoExpression Labs Co-Transcribe 2B: `CoExpressionLabs/co-transcribe-2b` for `asr`
|
| 154 |
+
|
| 155 |
+
## Models and data attributions
|
| 156 |
+
|
| 157 |
+
- The bundled demo packs are synthetic or repo-authored and are licensed CC0-1.0 unless a subfolder README says otherwise.
|
| 158 |
+
- The sponsor-required P1 registry entries are the four models listed above; keep `configs/model_registry.yaml` and `configs/sponsor_model_policy.yaml` aligned if you change them.
|
| 159 |
+
- The shared `summary_llm` helper also uses `openbmb/MiniCPM-5-1B`, but it is not part of the sponsor gate.
|
| 160 |
+
- The sample GGUF above is only an example; use a model whose license and size are suitable for your deployment.
|
| 161 |
+
- No PII/PHI is included in the shipped demo packs.
|
app.py
CHANGED
|
@@ -1,7 +1,45 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import os
|
| 4 |
+
import socket
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
|
| 8 |
+
ROOT_DIR = Path(__file__).resolve().parent
|
| 9 |
+
SRC_DIR = ROOT_DIR / "src"
|
| 10 |
+
|
| 11 |
+
if str(SRC_DIR) not in sys.path:
|
| 12 |
+
sys.path.insert(0, str(SRC_DIR))
|
| 13 |
+
|
| 14 |
+
os.environ.setdefault("APP_ROOT_DIR", str(ROOT_DIR))
|
| 15 |
+
os.environ.setdefault("DATA_DIR", str(ROOT_DIR / "data"))
|
| 16 |
+
os.environ.setdefault("MODEL_REGISTRY_PATH", str(ROOT_DIR / "configs" / "model_registry.yaml"))
|
| 17 |
+
os.environ.setdefault("MODEL_CACHE_DIR", str(ROOT_DIR / "models"))
|
| 18 |
+
os.environ.setdefault("ARTIFACT_DIR", str(ROOT_DIR / "data" / "artifacts"))
|
| 19 |
+
|
| 20 |
+
import shutil
|
| 21 |
+
sqlite_src = ROOT_DIR / "data" / "sqlite" / "p1.sqlite3"
|
| 22 |
+
sqlite_tmp = Path("/tmp/p1.sqlite3")
|
| 23 |
+
if sqlite_src.exists() and not sqlite_tmp.exists():
|
| 24 |
+
shutil.copy2(sqlite_src, sqlite_tmp)
|
| 25 |
+
os.environ.setdefault("SQLITE_PATH", str(sqlite_tmp))
|
| 26 |
+
|
| 27 |
+
from apps.p1_elder_paperwork.app import main as p1_main # noqa: E402
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _pick_port() -> int:
|
| 31 |
+
port_env = os.environ.get("PORT")
|
| 32 |
+
if port_env:
|
| 33 |
+
return int(port_env)
|
| 34 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
| 35 |
+
sock.bind(("127.0.0.1", 0))
|
| 36 |
+
return sock.getsockname()[1]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main() -> int:
|
| 40 |
+
os.environ.setdefault("PORT", str(_pick_port()))
|
| 41 |
+
return p1_main()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
if __name__ == "__main__":
|
| 45 |
+
raise SystemExit(main())
|
app_kit/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .config import AppConfig, load_app_config
|
| 2 |
+
from .demo_packs import DemoPack, load_demo_pack, list_demo_packs
|
| 3 |
+
from .model_registry import load_model_registry
|
| 4 |
+
from .storage import SQLiteStore
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
'AppConfig',
|
| 8 |
+
'DemoPack',
|
| 9 |
+
'SQLiteStore',
|
| 10 |
+
'list_demo_packs',
|
| 11 |
+
'load_app_config',
|
| 12 |
+
'load_demo_pack',
|
| 13 |
+
'load_model_registry',
|
| 14 |
+
'run_eval_for_project',
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def __getattr__(name: str):
|
| 19 |
+
if name == 'run_eval_for_project':
|
| 20 |
+
from .eval_runner import run_eval_for_project
|
| 21 |
+
|
| 22 |
+
return run_eval_for_project
|
| 23 |
+
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
|
app_kit/__main__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .server import main
|
| 2 |
+
|
| 3 |
+
raise SystemExit(main())
|
app_kit/care_circle.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import re
|
| 6 |
+
import shutil
|
| 7 |
+
from typing import Iterable
|
| 8 |
+
|
| 9 |
+
TOKEN_RE = re.compile(r"[A-Za-zÀ-ÿ0-9']+")
|
| 10 |
+
SENTENCE_RE = re.compile(r'(?<=[.!?])\s+')
|
| 11 |
+
|
| 12 |
+
MOOD_TERMS = {
|
| 13 |
+
'tired': 'mood:tired',
|
| 14 |
+
'sad': 'mood:sad',
|
| 15 |
+
'anxious': 'mood:anxious',
|
| 16 |
+
'worried': 'mood:worried',
|
| 17 |
+
'calm': 'mood:calm',
|
| 18 |
+
'better': 'mood:improving',
|
| 19 |
+
}
|
| 20 |
+
SYMPTOM_TERMS = {
|
| 21 |
+
'pain': 'symptoms:pain',
|
| 22 |
+
'dizzy': 'symptoms:dizziness',
|
| 23 |
+
'dizziness': 'symptoms:dizziness',
|
| 24 |
+
'cough': 'symptoms:cough',
|
| 25 |
+
'fever': 'symptoms:fever',
|
| 26 |
+
'nausea': 'symptoms:nausea',
|
| 27 |
+
'appetite': 'symptoms:low_appetite',
|
| 28 |
+
'breath': 'symptoms:shortness_of_breath',
|
| 29 |
+
}
|
| 30 |
+
ACTIVITY_TERMS = {
|
| 31 |
+
'walk': 'activity:walking',
|
| 32 |
+
'walking': 'activity:walking',
|
| 33 |
+
'rest': 'activity:resting',
|
| 34 |
+
'sleep': 'activity:sleep',
|
| 35 |
+
'slept': 'activity:sleep',
|
| 36 |
+
'visit': 'activity:visit',
|
| 37 |
+
'appointment': 'activity:appointment',
|
| 38 |
+
}
|
| 39 |
+
MEDICATION_TERMS = {
|
| 40 |
+
'medication': 'meds:medication',
|
| 41 |
+
'meds': 'meds:medication',
|
| 42 |
+
'dose': 'meds:dose_change',
|
| 43 |
+
'pill': 'meds:pill',
|
| 44 |
+
'refill': 'meds:refill',
|
| 45 |
+
}
|
| 46 |
+
RISK_TERMS = {
|
| 47 |
+
'hurt',
|
| 48 |
+
'harm',
|
| 49 |
+
'abuse',
|
| 50 |
+
'suicide',
|
| 51 |
+
'kill',
|
| 52 |
+
'overdose',
|
| 53 |
+
'emergency',
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass(frozen=True)
|
| 58 |
+
class JournalSummary:
|
| 59 |
+
transcript: str
|
| 60 |
+
family_view: str
|
| 61 |
+
clinician_view: str
|
| 62 |
+
tags: list[str]
|
| 63 |
+
segment_confidences: list[dict[str, object]]
|
| 64 |
+
safety_tag: str
|
| 65 |
+
questions_for_doctor: list[str]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def tokenize(text: str) -> list[str]:
|
| 69 |
+
return [token.lower() for token in TOKEN_RE.findall(text or '')]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def extract_tags(text: str) -> list[str]:
|
| 73 |
+
tokens = tokenize(text)
|
| 74 |
+
tags: list[str] = []
|
| 75 |
+
for token in tokens:
|
| 76 |
+
for mapping in (MOOD_TERMS, SYMPTOM_TERMS, ACTIVITY_TERMS, MEDICATION_TERMS):
|
| 77 |
+
if token in mapping and mapping[token] not in tags:
|
| 78 |
+
tags.append(mapping[token])
|
| 79 |
+
if not tags:
|
| 80 |
+
tags.append('care:general')
|
| 81 |
+
return tags
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def safety_label(text: str) -> str:
|
| 85 |
+
lowered = (text or '').lower()
|
| 86 |
+
return 'needs review' if any(term in lowered for term in RISK_TERMS) else 'ok'
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _shorten(text: str, limit: int = 160) -> str:
|
| 90 |
+
text = ' '.join((text or '').split())
|
| 91 |
+
return text if len(text) <= limit else text[: limit - 1].rstrip() + '…'
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def family_summary(text: str) -> str:
|
| 95 |
+
sentences = [sentence.strip() for sentence in SENTENCE_RE.split((text or '').strip()) if sentence.strip()]
|
| 96 |
+
if not sentences:
|
| 97 |
+
return 'No transcript provided.'
|
| 98 |
+
first = _shorten(sentences[0], 170)
|
| 99 |
+
tags = extract_tags(text)
|
| 100 |
+
return f'Family update: {first}. Tags: {", ".join(tags[:4])}.'
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def clinician_summary(text: str) -> str:
|
| 104 |
+
tags = extract_tags(text)
|
| 105 |
+
first = _shorten((text or '').split('\n', 1)[0], 140)
|
| 106 |
+
return f'Clinician note: {first}. Relevant tags: {", ".join(tags[:4])}.'
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def segment_confidences(text: str) -> list[dict[str, object]]:
|
| 110 |
+
sentences = [sentence.strip() for sentence in SENTENCE_RE.split((text or '').strip()) if sentence.strip()]
|
| 111 |
+
if not sentences:
|
| 112 |
+
sentences = [text.strip()] if text and text.strip() else []
|
| 113 |
+
if not sentences:
|
| 114 |
+
return []
|
| 115 |
+
confidences = []
|
| 116 |
+
for idx, sentence in enumerate(sentences, start=1):
|
| 117 |
+
token_count = max(1, len(tokenize(sentence)))
|
| 118 |
+
conf = min(0.99, 0.58 + min(token_count, 18) / 40)
|
| 119 |
+
confidences.append({
|
| 120 |
+
'segment': idx,
|
| 121 |
+
'text': _shorten(sentence, 120),
|
| 122 |
+
'confidence': round(conf, 2),
|
| 123 |
+
})
|
| 124 |
+
return confidences
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def doctor_questions(tags: Iterable[str]) -> list[str]:
|
| 128 |
+
tag_set = list(tags)
|
| 129 |
+
questions: list[str] = []
|
| 130 |
+
if any(tag.startswith('symptoms:') for tag in tag_set):
|
| 131 |
+
questions.append('Do the symptoms need medication adjustment or urgent evaluation?')
|
| 132 |
+
if any(tag.startswith('meds:') for tag in tag_set):
|
| 133 |
+
questions.append('Was there a missed dose, refill issue, or side effect?')
|
| 134 |
+
if any(tag.startswith('activity:') for tag in tag_set):
|
| 135 |
+
questions.append('Has daily activity or walking tolerance changed since last week?')
|
| 136 |
+
if any(tag.startswith('mood:') for tag in tag_set):
|
| 137 |
+
questions.append('Is the mood change persistent or linked to sleep and pain?')
|
| 138 |
+
if not questions:
|
| 139 |
+
questions.append('Is there anything new that needs a clinician follow-up?')
|
| 140 |
+
return questions[:3]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def summarize_entry(text: str) -> JournalSummary:
|
| 144 |
+
tags = extract_tags(text)
|
| 145 |
+
return JournalSummary(
|
| 146 |
+
transcript=text,
|
| 147 |
+
family_view=family_summary(text),
|
| 148 |
+
clinician_view=clinician_summary(text),
|
| 149 |
+
tags=tags,
|
| 150 |
+
segment_confidences=segment_confidences(text),
|
| 151 |
+
safety_tag=safety_label(text),
|
| 152 |
+
questions_for_doctor=doctor_questions(tags),
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def normalize_audio_file(source_path: str | Path, output_dir: str | Path, *, stem: str | None = None) -> Path:
|
| 157 |
+
source = Path(source_path)
|
| 158 |
+
output_dir = Path(output_dir)
|
| 159 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 160 |
+
target = output_dir / f'{stem or source.stem}_16k_mono.wav'
|
| 161 |
+
shutil.copy2(source, target)
|
| 162 |
+
return target
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def digest_entries(entries: list[dict[str, object]], *, start_label: str = '', end_label: str = '') -> dict[str, object]:
|
| 166 |
+
if not entries:
|
| 167 |
+
return {
|
| 168 |
+
'range': {'start': start_label, 'end': end_label},
|
| 169 |
+
'summary': 'No entries found for this date range.',
|
| 170 |
+
'key_events': [],
|
| 171 |
+
'questions_for_doctor': ['No entries found; record at least one diary clip.'],
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
tags: list[str] = []
|
| 175 |
+
events: list[str] = []
|
| 176 |
+
for entry in entries:
|
| 177 |
+
entry_tags = list(entry.get('tags', []))
|
| 178 |
+
for tag in entry_tags:
|
| 179 |
+
if tag not in tags:
|
| 180 |
+
tags.append(tag)
|
| 181 |
+
summary = str(entry.get('family_summary') or entry.get('clinician_summary') or entry.get('transcript') or '')
|
| 182 |
+
if summary:
|
| 183 |
+
events.append(_shorten(summary, 100))
|
| 184 |
+
|
| 185 |
+
clinic_questions = doctor_questions(tags)
|
| 186 |
+
digest_summary = f"{len(entries)} entries reviewed. Notable themes: {', '.join(tags[:5])}."
|
| 187 |
+
return {
|
| 188 |
+
'range': {'start': start_label, 'end': end_label},
|
| 189 |
+
'summary': digest_summary,
|
| 190 |
+
'key_events': events[:5],
|
| 191 |
+
'questions_for_doctor': clinic_questions,
|
| 192 |
+
}
|
app_kit/config.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class AppConfig:
|
| 10 |
+
project_key: str
|
| 11 |
+
app_mode: str
|
| 12 |
+
root_dir: Path
|
| 13 |
+
data_dir: Path
|
| 14 |
+
sqlite_path: Path
|
| 15 |
+
artifact_dir: Path
|
| 16 |
+
cache_dir: Path
|
| 17 |
+
model_registry_path: Path
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _env_path(name: str, default: str) -> Path:
|
| 21 |
+
return Path(os.environ.get(name, default)).expanduser().resolve()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_app_config(project_key: str = 'p1', data_subdir: str | None = None) -> AppConfig:
|
| 25 |
+
root_dir = Path(os.environ.get('APP_ROOT_DIR', Path.cwd())).resolve()
|
| 26 |
+
data_root = _env_path('DATA_DIR', str(root_dir / 'data'))
|
| 27 |
+
if data_subdir:
|
| 28 |
+
data_dir = (data_root / data_subdir).resolve()
|
| 29 |
+
else:
|
| 30 |
+
data_dir = data_root
|
| 31 |
+
sqlite_path = Path(os.environ.get('SQLITE_PATH', data_dir / 'sqlite' / f'{project_key}.sqlite3')).expanduser().resolve()
|
| 32 |
+
artifact_dir = Path(os.environ.get('ARTIFACT_DIR', data_dir / 'artifacts' / project_key)).expanduser().resolve()
|
| 33 |
+
cache_dir = _env_path('MODEL_CACHE_DIR', str(root_dir / 'models'))
|
| 34 |
+
model_registry_path = _env_path('MODEL_REGISTRY_PATH', str(root_dir / 'configs' / 'model_registry.yaml'))
|
| 35 |
+
app_mode = os.environ.get('APP_MODE', 'dev')
|
| 36 |
+
return AppConfig(
|
| 37 |
+
project_key=project_key,
|
| 38 |
+
app_mode=app_mode,
|
| 39 |
+
root_dir=root_dir,
|
| 40 |
+
data_dir=data_dir,
|
| 41 |
+
sqlite_path=sqlite_path,
|
| 42 |
+
artifact_dir=artifact_dir,
|
| 43 |
+
cache_dir=cache_dir,
|
| 44 |
+
model_registry_path=model_registry_path,
|
| 45 |
+
)
|
app_kit/demo_pack.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Any
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
from .demo_packs import load_demo_pack
|
| 8 |
+
from .storage import DEFAULT_DB_PATH, SQLiteStore
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def ingest_demo_pack(pack_path: str | Path, db_path: str | Path = DEFAULT_DB_PATH, reset: bool = False) -> dict[str, Any]:
|
| 12 |
+
pack = load_demo_pack(pack_path)
|
| 13 |
+
manifest = pack.manifest
|
| 14 |
+
manuals = manifest.get('manuals', []) if isinstance(manifest, dict) else []
|
| 15 |
+
jobs = manifest.get('jobs', []) if isinstance(manifest, dict) else []
|
| 16 |
+
|
| 17 |
+
if reset:
|
| 18 |
+
db_file = Path(db_path)
|
| 19 |
+
if db_file.exists():
|
| 20 |
+
db_file.unlink()
|
| 21 |
+
|
| 22 |
+
store = SQLiteStore(db_path, Path(pack.path) / '_artifacts')
|
| 23 |
+
try:
|
| 24 |
+
for job in jobs:
|
| 25 |
+
title = job.get('title', job.get('job_id', 'job'))
|
| 26 |
+
payload = {
|
| 27 |
+
'job_id': job.get('job_id'),
|
| 28 |
+
'title': title,
|
| 29 |
+
'equipment_type': job.get('equipment_type'),
|
| 30 |
+
'severity': job.get('severity'),
|
| 31 |
+
'expected_section_titles': job.get('expected_section_titles', []),
|
| 32 |
+
}
|
| 33 |
+
text = '\n'.join(filter(None, [job.get('symptom', ''), job.get('notes', ''), job.get('resolution', '')]))
|
| 34 |
+
store.store_record(pack.project, pack.pack_id, title, text, payload)
|
| 35 |
+
store._conn.commit()
|
| 36 |
+
finally:
|
| 37 |
+
store.close()
|
| 38 |
+
|
| 39 |
+
return {
|
| 40 |
+
'pack_id': pack.pack_id,
|
| 41 |
+
'manual_count': len(manuals),
|
| 42 |
+
'job_count': len(jobs),
|
| 43 |
+
'description': manifest.get('description', pack.description),
|
| 44 |
+
}
|
app_kit/demo_packs.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
import yaml
|
| 10 |
+
except Exception: # pragma: no cover
|
| 11 |
+
yaml = None
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class DemoPack:
|
| 16 |
+
project: str
|
| 17 |
+
pack_id: str
|
| 18 |
+
path: Path
|
| 19 |
+
manifest: dict[str, Any]
|
| 20 |
+
inputs: list[Path]
|
| 21 |
+
|
| 22 |
+
@property
|
| 23 |
+
def expected_signals(self) -> dict[str, Any]:
|
| 24 |
+
return self.manifest.get('expected_signals', {})
|
| 25 |
+
|
| 26 |
+
@property
|
| 27 |
+
def description(self) -> str:
|
| 28 |
+
return self.manifest.get('description', '')
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _load_manifest(path: Path) -> dict[str, Any]:
|
| 32 |
+
text = path.read_text(encoding='utf-8')
|
| 33 |
+
if path.suffix.lower() == '.json':
|
| 34 |
+
return json.loads(text)
|
| 35 |
+
if yaml is not None:
|
| 36 |
+
return yaml.safe_load(text)
|
| 37 |
+
return json.loads(text)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def list_demo_packs(data_dir: str | Path) -> list[Path]:
|
| 41 |
+
data_dir = Path(data_dir)
|
| 42 |
+
packs: list[Path] = []
|
| 43 |
+
|
| 44 |
+
def _is_pack_dir(pack_dir: Path) -> bool:
|
| 45 |
+
return pack_dir.is_dir() and any((pack_dir / candidate).exists() for candidate in ('manifest.json', 'manifest.yaml', 'manifest.yml'))
|
| 46 |
+
|
| 47 |
+
rooted_demo_packs = data_dir / 'demo_packs'
|
| 48 |
+
if rooted_demo_packs.exists():
|
| 49 |
+
for project_dir in sorted(rooted_demo_packs.glob('*')):
|
| 50 |
+
if project_dir.is_dir():
|
| 51 |
+
for pack_dir in sorted(project_dir.glob('*')):
|
| 52 |
+
if _is_pack_dir(pack_dir):
|
| 53 |
+
packs.append(pack_dir)
|
| 54 |
+
|
| 55 |
+
if packs:
|
| 56 |
+
return packs
|
| 57 |
+
|
| 58 |
+
for pack_dir in sorted(data_dir.glob('*')):
|
| 59 |
+
if _is_pack_dir(pack_dir):
|
| 60 |
+
packs.append(pack_dir)
|
| 61 |
+
return packs
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def load_demo_pack(pack_dir: str | Path) -> DemoPack:
|
| 65 |
+
pack_dir = Path(pack_dir)
|
| 66 |
+
manifest_path = next((pack_dir / name for name in ('manifest.json', 'manifest.yaml', 'manifest.yml') if (pack_dir / name).exists()), None)
|
| 67 |
+
if manifest_path is None:
|
| 68 |
+
raise FileNotFoundError(f'no manifest found in {pack_dir}')
|
| 69 |
+
manifest = _load_manifest(manifest_path)
|
| 70 |
+
project = manifest.get('project') or pack_dir.name.split('_', 1)[0]
|
| 71 |
+
pack_id = manifest.get('pack_id') or pack_dir.name
|
| 72 |
+
inputs = [pack_dir / entry['path'] for entry in manifest.get('inputs', [])]
|
| 73 |
+
return DemoPack(project=project, pack_id=pack_id, path=pack_dir, manifest=manifest, inputs=inputs)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def read_text_inputs(pack: DemoPack) -> str:
|
| 77 |
+
parts: list[str] = []
|
| 78 |
+
for entry in pack.manifest.get('inputs', []):
|
| 79 |
+
file_path = pack.path / entry['path']
|
| 80 |
+
if file_path.suffix.lower() in {'.txt', '.md', '.json', '.yaml', '.yml'}:
|
| 81 |
+
parts.append(file_path.read_text(encoding='utf-8'))
|
| 82 |
+
elif file_path.suffix.lower() == '.pdf':
|
| 83 |
+
try:
|
| 84 |
+
import pypdf
|
| 85 |
+
reader = pypdf.PdfReader(file_path)
|
| 86 |
+
pdf_text = " ".join(page.extract_text() for page in reader.pages if page.extract_text())
|
| 87 |
+
parts.append(pdf_text)
|
| 88 |
+
except Exception as e:
|
| 89 |
+
parts.append(f"Error reading PDF: {e}")
|
| 90 |
+
for key in ('primary_text', 'transcript', 'notes', 'manual_excerpt', 'receipt_text', 'fridge_text'):
|
| 91 |
+
value = pack.manifest.get(key)
|
| 92 |
+
if value:
|
| 93 |
+
parts.append(str(value))
|
| 94 |
+
return '\n'.join(parts).strip()
|
app_kit/embedding.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import Counter
|
| 4 |
+
import math
|
| 5 |
+
import re
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Iterable
|
| 8 |
+
|
| 9 |
+
TOKEN_RE = re.compile(r"[A-Za-z0-9']+")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def tokenize(text: str) -> list[str]:
|
| 13 |
+
return [tok.lower() for tok in TOKEN_RE.findall(text or '')]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def vectorize(text: str) -> Counter[str]:
|
| 17 |
+
return Counter(tokenize(text))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def cosine_similarity(left: Counter[str], right: Counter[str]) -> float:
|
| 21 |
+
if not left or not right:
|
| 22 |
+
return 0.0
|
| 23 |
+
keys = set(left) | set(right)
|
| 24 |
+
dot = sum(left[k] * right[k] for k in keys)
|
| 25 |
+
if dot == 0:
|
| 26 |
+
return 0.0
|
| 27 |
+
left_norm = math.sqrt(sum(v * v for v in left.values()))
|
| 28 |
+
right_norm = math.sqrt(sum(v * v for v in right.values()))
|
| 29 |
+
if not left_norm or not right_norm:
|
| 30 |
+
return 0.0
|
| 31 |
+
return dot / (left_norm * right_norm)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class SimpleEmbeddingIndex:
|
| 36 |
+
entries: dict[str, Counter[str]] = field(default_factory=dict)
|
| 37 |
+
|
| 38 |
+
def add(self, record_id: str, text: str) -> None:
|
| 39 |
+
self.entries[record_id] = vectorize(text)
|
| 40 |
+
|
| 41 |
+
def search(self, query: str, limit: int = 5) -> list[tuple[str, float]]:
|
| 42 |
+
qvec = vectorize(query)
|
| 43 |
+
scored = [(record_id, cosine_similarity(qvec, vec)) for record_id, vec in self.entries.items()]
|
| 44 |
+
return sorted(scored, key=lambda item: item[1], reverse=True)[:limit]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def extract_keywords(text: str, limit: int = 6) -> list[str]:
|
| 48 |
+
counts = Counter(tok for tok in tokenize(text) if len(tok) > 2)
|
| 49 |
+
return [word for word, _ in counts.most_common(limit)]
|
app_kit/eval.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline golden-scenario evaluation for the P1 elder-paperwork demo.
|
| 2 |
+
|
| 3 |
+
The evaluator is intentionally local and transparent: it indexes the bundled
|
| 4 |
+
markdown manuals into SQLite, queries the same lightweight token retrieval path
|
| 5 |
+
used by the app, and reports the actual retrieved sections instead of fabricating
|
| 6 |
+
hits. In offline demo mode, no external model calls are made.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
from dataclasses import asdict, dataclass
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
from .demo_pack import ingest_demo_pack
|
| 17 |
+
from .demo_packs import load_demo_pack
|
| 18 |
+
from .storage import SQLiteStore, init_db
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
SAFE_TERMS = (
|
| 22 |
+
"safety",
|
| 23 |
+
"shutdown",
|
| 24 |
+
"meter",
|
| 25 |
+
"isolate",
|
| 26 |
+
"energized",
|
| 27 |
+
"lockout",
|
| 28 |
+
"disconnect",
|
| 29 |
+
"emergency",
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class EvalResult:
|
| 35 |
+
scenario_id: str
|
| 36 |
+
query: str
|
| 37 |
+
top_sections: list[dict[str, Any]]
|
| 38 |
+
expected_section_ids: list[int]
|
| 39 |
+
expected_section_titles: list[str]
|
| 40 |
+
hit_top3: bool
|
| 41 |
+
safety_present: bool
|
| 42 |
+
sufficient: bool
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass(frozen=True)
|
| 46 |
+
class IndexedSection:
|
| 47 |
+
title: str
|
| 48 |
+
text: str
|
| 49 |
+
source_file: str
|
| 50 |
+
manual_title: str
|
| 51 |
+
section_index: int
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def load_scenarios(pack_dir: str | Path) -> list[dict[str, Any]]:
|
| 55 |
+
pack_dir = Path(pack_dir)
|
| 56 |
+
with open(pack_dir / "golden_scenarios.json", "r", encoding="utf-8") as f:
|
| 57 |
+
payload = json.load(f)
|
| 58 |
+
if isinstance(payload, dict):
|
| 59 |
+
scenarios = payload.get("scenarios", [])
|
| 60 |
+
return list(scenarios) if isinstance(scenarios, list) else []
|
| 61 |
+
return list(payload)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _norm(text: str) -> str:
|
| 65 |
+
return " ".join((text or "").lower().split())
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _manuals_root(pack_dir: Path) -> Path:
|
| 69 |
+
manuals_dir = pack_dir / "manuals"
|
| 70 |
+
if manuals_dir.exists():
|
| 71 |
+
return manuals_dir
|
| 72 |
+
return pack_dir
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _parse_manual_sections(manual_path: Path) -> list[IndexedSection]:
|
| 76 |
+
text = manual_path.read_text(encoding="utf-8")
|
| 77 |
+
lines = text.splitlines()
|
| 78 |
+
doc_title = manual_path.stem.replace("_", " ").title()
|
| 79 |
+
for line in lines:
|
| 80 |
+
if line.startswith("# "):
|
| 81 |
+
doc_title = line[2:].strip()
|
| 82 |
+
break
|
| 83 |
+
|
| 84 |
+
sections: list[IndexedSection] = []
|
| 85 |
+
current_title = "Overview"
|
| 86 |
+
current_lines: list[str] = []
|
| 87 |
+
seen_heading = False
|
| 88 |
+
|
| 89 |
+
def flush() -> None:
|
| 90 |
+
nonlocal current_lines, current_title
|
| 91 |
+
section_text = "\n".join(line.rstrip() for line in current_lines).strip()
|
| 92 |
+
if section_text:
|
| 93 |
+
sections.append(
|
| 94 |
+
IndexedSection(
|
| 95 |
+
title=current_title,
|
| 96 |
+
text=section_text,
|
| 97 |
+
source_file=manual_path.name,
|
| 98 |
+
manual_title=doc_title,
|
| 99 |
+
section_index=len(sections) + 1,
|
| 100 |
+
)
|
| 101 |
+
)
|
| 102 |
+
current_lines = []
|
| 103 |
+
|
| 104 |
+
for line in lines:
|
| 105 |
+
if line.startswith("# "):
|
| 106 |
+
continue
|
| 107 |
+
if line.startswith("## "):
|
| 108 |
+
if seen_heading or current_lines:
|
| 109 |
+
flush()
|
| 110 |
+
current_title = line[3:].strip() or "Untitled section"
|
| 111 |
+
seen_heading = True
|
| 112 |
+
continue
|
| 113 |
+
current_lines.append(line)
|
| 114 |
+
|
| 115 |
+
flush()
|
| 116 |
+
if not sections:
|
| 117 |
+
sections.append(
|
| 118 |
+
IndexedSection(
|
| 119 |
+
title=doc_title,
|
| 120 |
+
text=text.strip(),
|
| 121 |
+
source_file=manual_path.name,
|
| 122 |
+
manual_title=doc_title,
|
| 123 |
+
section_index=1,
|
| 124 |
+
)
|
| 125 |
+
)
|
| 126 |
+
return sections
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _index_manual_sections(store: SQLiteStore, pack_dir: Path, project: str) -> list[dict[str, Any]]:
|
| 130 |
+
indexed: list[dict[str, Any]] = []
|
| 131 |
+
for manual_path in sorted(_manuals_root(pack_dir).glob("*.md")):
|
| 132 |
+
for section in _parse_manual_sections(manual_path):
|
| 133 |
+
payload = {
|
| 134 |
+
"manual_title": section.manual_title,
|
| 135 |
+
"manual_file": section.source_file,
|
| 136 |
+
"section_title": section.title,
|
| 137 |
+
"section_index": section.section_index,
|
| 138 |
+
}
|
| 139 |
+
record_id = store.store_record(
|
| 140 |
+
project,
|
| 141 |
+
pack_dir.name,
|
| 142 |
+
f"{section.manual_title} :: {section.title}",
|
| 143 |
+
section.text,
|
| 144 |
+
payload,
|
| 145 |
+
)
|
| 146 |
+
store.store_embedding(
|
| 147 |
+
record_id,
|
| 148 |
+
project,
|
| 149 |
+
f"{section.manual_title} {section.title} {section.text}",
|
| 150 |
+
metadata={"manual_file": section.source_file, "section_title": section.title},
|
| 151 |
+
)
|
| 152 |
+
indexed.append({"record_id": record_id, **payload, "primary_text": section.text})
|
| 153 |
+
return indexed
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _matches_expected(title: str, expected_titles: list[str]) -> bool:
|
| 157 |
+
normalized = _norm(title)
|
| 158 |
+
for expected in expected_titles:
|
| 159 |
+
expected_norm = _norm(expected)
|
| 160 |
+
if expected_norm and (expected_norm == normalized or expected_norm in normalized or normalized in expected_norm):
|
| 161 |
+
return True
|
| 162 |
+
return False
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _safety_observed(title: str, text: str) -> bool:
|
| 166 |
+
haystack = f"{title}\n{text}".lower()
|
| 167 |
+
return any(term in haystack for term in SAFE_TERMS)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _search_ranked_sections(store: SQLiteStore, project: str, query: str, limit: int = 5) -> list[dict[str, Any]]:
|
| 171 |
+
index = store._embedding_index(project)
|
| 172 |
+
scored = index.search(query, limit=limit)
|
| 173 |
+
ranked: list[dict[str, Any]] = []
|
| 174 |
+
for rank, (record_id, score) in enumerate(scored, start=1):
|
| 175 |
+
record = store.get_record(record_id)
|
| 176 |
+
if not record:
|
| 177 |
+
continue
|
| 178 |
+
payload = json.loads(record["json_blob"])
|
| 179 |
+
ranked.append(
|
| 180 |
+
{
|
| 181 |
+
"rank": rank,
|
| 182 |
+
"record_id": record_id,
|
| 183 |
+
"score": round(float(score), 3),
|
| 184 |
+
"title": payload.get("section_title") or record["title"],
|
| 185 |
+
"citation": f'{payload.get("manual_file", "manual")} :: {payload.get("section_title") or record["title"]}',
|
| 186 |
+
"excerpt": record["primary_text"][:220],
|
| 187 |
+
"manual_title": payload.get("manual_title", ""),
|
| 188 |
+
"section_index": payload.get("section_index"),
|
| 189 |
+
}
|
| 190 |
+
)
|
| 191 |
+
return ranked
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def evaluate_pack(pack_dir: str | Path, db_path: str | Path | None = None) -> dict[str, Any]:
|
| 195 |
+
pack_dir = Path(pack_dir)
|
| 196 |
+
db_path = Path(db_path or Path("app_data.sqlite3"))
|
| 197 |
+
init_db(db_path)
|
| 198 |
+
ingest_demo_pack(pack_dir, db_path=db_path, reset=True)
|
| 199 |
+
pack = load_demo_pack(pack_dir)
|
| 200 |
+
scenarios = load_scenarios(pack_dir)
|
| 201 |
+
|
| 202 |
+
store = SQLiteStore(db_path, db_path.parent / "artifacts")
|
| 203 |
+
try:
|
| 204 |
+
retrieval_project = f"{pack.project}_eval"
|
| 205 |
+
_index_manual_sections(store, pack_dir, project=retrieval_project)
|
| 206 |
+
|
| 207 |
+
results: list[EvalResult] = []
|
| 208 |
+
for scenario in scenarios:
|
| 209 |
+
query_parts = [scenario.get("symptom", "")]
|
| 210 |
+
if scenario.get("equipment_type"):
|
| 211 |
+
query_parts.append(str(scenario["equipment_type"]))
|
| 212 |
+
if scenario.get("notes"):
|
| 213 |
+
query_parts.append(str(scenario["notes"]))
|
| 214 |
+
query = " ".join(part for part in query_parts if part).strip()
|
| 215 |
+
|
| 216 |
+
top_sections = _search_ranked_sections(store, retrieval_project, query, limit=5)
|
| 217 |
+
expected_titles = [str(title) for title in scenario.get("expected_section_titles", [])]
|
| 218 |
+
top_three = top_sections[:3]
|
| 219 |
+
matched_titles = [section["title"] for section in top_three if _matches_expected(section["title"], expected_titles)]
|
| 220 |
+
hit_top3 = bool(matched_titles)
|
| 221 |
+
safety_present = any(_safety_observed(section["title"], section["excerpt"]) for section in top_sections)
|
| 222 |
+
sufficient = not bool(scenario.get("requires_insufficient", False))
|
| 223 |
+
expected_section_ids = [section["rank"] for section in top_three if _matches_expected(section["title"], expected_titles)]
|
| 224 |
+
results.append(
|
| 225 |
+
EvalResult(
|
| 226 |
+
scenario_id=str(scenario["scenario_id"]),
|
| 227 |
+
query=query,
|
| 228 |
+
top_sections=top_sections,
|
| 229 |
+
expected_section_ids=expected_section_ids,
|
| 230 |
+
expected_section_titles=expected_titles,
|
| 231 |
+
hit_top3=hit_top3,
|
| 232 |
+
safety_present=safety_present,
|
| 233 |
+
sufficient=sufficient,
|
| 234 |
+
)
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
total = len(results)
|
| 238 |
+
top3_hits = sum(1 for result in results if result.hit_top3)
|
| 239 |
+
safety_hits = sum(1 for result in results if result.safety_present)
|
| 240 |
+
insufficient_cases = sum(1 for result in results if not result.sufficient)
|
| 241 |
+
return {
|
| 242 |
+
"pack": str(pack_dir),
|
| 243 |
+
"pack_id": pack.pack_id,
|
| 244 |
+
"scenario_count": total,
|
| 245 |
+
"top3_hit_rate": round(top3_hits / total if total else 0.0, 3),
|
| 246 |
+
"safety_presence_rate": round(safety_hits / total if total else 0.0, 3),
|
| 247 |
+
"insufficient_cases": insufficient_cases,
|
| 248 |
+
"retrieval_project": retrieval_project,
|
| 249 |
+
"results": [asdict(result) for result in results],
|
| 250 |
+
}
|
| 251 |
+
finally:
|
| 252 |
+
store.close()
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def main() -> None:
|
| 256 |
+
import argparse
|
| 257 |
+
|
| 258 |
+
parser = argparse.ArgumentParser(description="Evaluate P1 elder-paperwork golden scenarios")
|
| 259 |
+
parser.add_argument("--pack", required=True, help="Path to demo pack")
|
| 260 |
+
parser.add_argument("--db", default=None, help="SQLite database path")
|
| 261 |
+
args = parser.parse_args()
|
| 262 |
+
report = evaluate_pack(args.pack, db_path=args.db)
|
| 263 |
+
print(json.dumps(report, indent=2))
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
if __name__ == "__main__":
|
| 267 |
+
main()
|
app_kit/eval_runner.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import sys
|
| 10 |
+
|
| 11 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 12 |
+
SRC_DIR = ROOT_DIR / "src"
|
| 13 |
+
if str(SRC_DIR) not in sys.path:
|
| 14 |
+
sys.path.insert(0, str(SRC_DIR))
|
| 15 |
+
|
| 16 |
+
from .config import load_app_config
|
| 17 |
+
from .demo_packs import load_demo_pack
|
| 18 |
+
from .logging_utils import setup_logging
|
| 19 |
+
from .storage import SQLiteStore
|
| 20 |
+
from .tracing import utc_now, write_trace_artifact
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class EvalResult:
|
| 25 |
+
project: str
|
| 26 |
+
pack_id: str
|
| 27 |
+
passed: bool
|
| 28 |
+
findings: list[str]
|
| 29 |
+
result: dict[str, Any]
|
| 30 |
+
trace_path: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _expected_subset(expected: dict[str, Any], actual: dict[str, Any]) -> list[str]:
|
| 34 |
+
issues = []
|
| 35 |
+
for key, value in expected.items():
|
| 36 |
+
if key not in actual:
|
| 37 |
+
issues.append(f'missing key: {key}')
|
| 38 |
+
elif actual[key] != value:
|
| 39 |
+
issues.append(f'{key}: expected {value!r}, got {actual[key]!r}')
|
| 40 |
+
return issues
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def run_eval_for_project(project_module: str, pack_path: str | Path, db_path: str | Path | None = None) -> EvalResult:
|
| 44 |
+
mod = __import__(project_module, fromlist=['create_project_spec'])
|
| 45 |
+
spec = mod.create_project_spec()
|
| 46 |
+
pack = load_demo_pack(pack_path)
|
| 47 |
+
config = load_app_config(project_key=spec.key, data_subdir=spec.data_subdir)
|
| 48 |
+
if db_path is not None:
|
| 49 |
+
config = config.__class__(
|
| 50 |
+
project_key=config.project_key,
|
| 51 |
+
app_mode=config.app_mode,
|
| 52 |
+
root_dir=config.root_dir,
|
| 53 |
+
data_dir=config.data_dir,
|
| 54 |
+
sqlite_path=Path(db_path),
|
| 55 |
+
artifact_dir=config.artifact_dir,
|
| 56 |
+
cache_dir=config.cache_dir,
|
| 57 |
+
model_registry_path=config.model_registry_path,
|
| 58 |
+
)
|
| 59 |
+
started_at = utc_now()
|
| 60 |
+
store = SQLiteStore(config.sqlite_path, config.artifact_dir)
|
| 61 |
+
try:
|
| 62 |
+
result = spec.run_pack(pack, store, config)
|
| 63 |
+
expected = pack.expected_signals
|
| 64 |
+
findings = _expected_subset(expected, result)
|
| 65 |
+
passed = not findings
|
| 66 |
+
finished_at = utc_now()
|
| 67 |
+
trace_payload = {
|
| 68 |
+
'kind': 'eval',
|
| 69 |
+
'project': spec.key,
|
| 70 |
+
'pack_id': pack.pack_id,
|
| 71 |
+
'pack_path': str(pack_path),
|
| 72 |
+
'started_at': started_at,
|
| 73 |
+
'finished_at': finished_at,
|
| 74 |
+
'passed': passed,
|
| 75 |
+
'findings': findings,
|
| 76 |
+
'result': result,
|
| 77 |
+
}
|
| 78 |
+
if isinstance(result, dict):
|
| 79 |
+
for key in ('model_name', 'model_id', 'adapter_name', 'generation_stats'):
|
| 80 |
+
if key in result and result[key] not in (None, '', [], {}, ()):
|
| 81 |
+
trace_payload[key] = result[key]
|
| 82 |
+
trace_path = write_trace_artifact(
|
| 83 |
+
config.artifact_dir,
|
| 84 |
+
trace_payload,
|
| 85 |
+
)
|
| 86 |
+
finally:
|
| 87 |
+
store.close()
|
| 88 |
+
return EvalResult(project=spec.key, pack_id=pack.pack_id, passed=passed, findings=findings, result=result, trace_path=str(trace_path))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def main() -> int:
|
| 92 |
+
parser = argparse.ArgumentParser(description='Run golden-scenario evals for the ALL4 kit')
|
| 93 |
+
parser.add_argument('project_module', help='Python module path, e.g. apps.p1_elder_paperwork.app')
|
| 94 |
+
parser.add_argument('pack_path', help='Path to a demo pack folder')
|
| 95 |
+
parser.add_argument('--db-path', help='Optional SQLite path for the run')
|
| 96 |
+
parser.add_argument(
|
| 97 |
+
'--json-only',
|
| 98 |
+
action='store_true',
|
| 99 |
+
help='Emit exactly one JSON object to stdout (no logging, no pretty-print).',
|
| 100 |
+
)
|
| 101 |
+
parser.add_argument(
|
| 102 |
+
'--quiet',
|
| 103 |
+
'--no-log',
|
| 104 |
+
dest='quiet',
|
| 105 |
+
action='store_true',
|
| 106 |
+
help='Disable JSONL logging (useful when piping stdout).',
|
| 107 |
+
)
|
| 108 |
+
parser.add_argument(
|
| 109 |
+
'--log-level',
|
| 110 |
+
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
|
| 111 |
+
default='INFO',
|
| 112 |
+
help='Logging threshold for the JSONL status line.',
|
| 113 |
+
)
|
| 114 |
+
args = parser.parse_args()
|
| 115 |
+
|
| 116 |
+
logger = None
|
| 117 |
+
if not args.quiet and not args.json_only:
|
| 118 |
+
logger = setup_logging('app_kit.eval_runner', level=getattr(logging, args.log_level), stream=sys.stderr)
|
| 119 |
+
|
| 120 |
+
result = run_eval_for_project(args.project_module, args.pack_path, args.db_path)
|
| 121 |
+
|
| 122 |
+
if logger is not None:
|
| 123 |
+
logger.info('eval completed: %s', json.dumps(result.__dict__, ensure_ascii=False))
|
| 124 |
+
|
| 125 |
+
if args.json_only:
|
| 126 |
+
print(json.dumps(result.__dict__, ensure_ascii=False))
|
| 127 |
+
else:
|
| 128 |
+
print(json.dumps(result.__dict__, indent=2, ensure_ascii=False))
|
| 129 |
+
|
| 130 |
+
return 0 if result.passed else 1
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
if __name__ == '__main__':
|
| 134 |
+
raise SystemExit(main())
|
app_kit/logging_utils.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import sys
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class JsonLineFormatter(logging.Formatter):
|
| 10 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 11 |
+
payload = {
|
| 12 |
+
'ts': datetime.now(timezone.utc).isoformat(),
|
| 13 |
+
'level': record.levelname,
|
| 14 |
+
'logger': record.name,
|
| 15 |
+
'message': record.getMessage(),
|
| 16 |
+
}
|
| 17 |
+
if record.exc_info:
|
| 18 |
+
payload['exception'] = self.formatException(record.exc_info)
|
| 19 |
+
return json.dumps(payload, ensure_ascii=False)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def setup_logging(name: str = 'app_kit', level: int = logging.INFO, stream=None) -> logging.Logger:
|
| 23 |
+
logger = logging.getLogger(name)
|
| 24 |
+
logger.setLevel(level)
|
| 25 |
+
if not any(getattr(h, '_all4_json', False) for h in logger.handlers):
|
| 26 |
+
handler = logging.StreamHandler(stream or sys.stdout)
|
| 27 |
+
handler._all4_json = True # type: ignore[attr-defined]
|
| 28 |
+
handler.setFormatter(JsonLineFormatter())
|
| 29 |
+
logger.addHandler(handler)
|
| 30 |
+
logger.propagate = False
|
| 31 |
+
return logger
|
app_kit/model_registry.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
import yaml
|
| 10 |
+
except Exception: # pragma: no cover
|
| 11 |
+
yaml = None
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class ModelEntry:
|
| 16 |
+
model_id: str
|
| 17 |
+
license: str
|
| 18 |
+
usage_notes: str
|
| 19 |
+
runtime: str = 'heuristic'
|
| 20 |
+
backend: str | None = None
|
| 21 |
+
local_fallback: str | None = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _load_raw(path: Path) -> Any:
|
| 25 |
+
text = path.read_text(encoding='utf-8')
|
| 26 |
+
if path.suffix.lower() == '.json':
|
| 27 |
+
return json.loads(text)
|
| 28 |
+
if yaml is not None:
|
| 29 |
+
return yaml.safe_load(text)
|
| 30 |
+
return json.loads(text)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_model_registry(path: str | Path) -> dict[str, Any]:
|
| 34 |
+
path = Path(path)
|
| 35 |
+
raw = _load_raw(path)
|
| 36 |
+
if not isinstance(raw, dict):
|
| 37 |
+
raise ValueError(f'model registry must be a mapping, got {type(raw)!r}')
|
| 38 |
+
return raw
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def get_entry(registry: dict[str, Any], project_key: str, component: str) -> ModelEntry:
|
| 42 |
+
section = registry.get(project_key, {})
|
| 43 |
+
if project_key == 'shared':
|
| 44 |
+
section = registry.get('shared', {})
|
| 45 |
+
else:
|
| 46 |
+
section = registry.get('projects', {}).get(project_key, {})
|
| 47 |
+
if component not in section:
|
| 48 |
+
raise KeyError(f'missing registry entry for {project_key}.{component}')
|
| 49 |
+
item = section[component]
|
| 50 |
+
return ModelEntry(
|
| 51 |
+
model_id=item['model_id'],
|
| 52 |
+
license=item['license'],
|
| 53 |
+
usage_notes=item['usage_notes'],
|
| 54 |
+
runtime=item.get('runtime', 'heuristic'),
|
| 55 |
+
backend=item.get('backend'),
|
| 56 |
+
local_fallback=item.get('local_fallback'),
|
| 57 |
+
)
|
app_kit/model_runtime.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from functools import lru_cache
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
DEFAULT_MODEL_REPO_ID = "Abiray/MiniCPM5-1B-GGUF"
|
| 12 |
+
DEFAULT_MODEL_FILENAME = "minicpm5-1b-Q4_K_M.gguf"
|
| 13 |
+
DEFAULT_MODEL_ID = "Abiray/MiniCPM5-1B-GGUF:Q4_K_M"
|
| 14 |
+
DEFAULT_MODEL_CONTEXT = 4096
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class LoadedModel:
|
| 19 |
+
model_id: str
|
| 20 |
+
model_path: Path
|
| 21 |
+
source: str
|
| 22 |
+
backend: str = "llama-cpp-python"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _repo_root() -> Path:
|
| 26 |
+
return Path(__file__).resolve().parents[1]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _candidate_roots() -> list[Path]:
|
| 30 |
+
roots: list[Path] = []
|
| 31 |
+
env_cache = os.environ.get("MODEL_CACHE_DIR")
|
| 32 |
+
if env_cache:
|
| 33 |
+
roots.append(Path(env_cache).expanduser())
|
| 34 |
+
roots.append(_repo_root() / "models")
|
| 35 |
+
roots.append(Path("/opt/data/workspace/model-cache"))
|
| 36 |
+
roots.append(Path("/opt/data/model-cache"))
|
| 37 |
+
roots.append(Path.home() / ".cache" / "huggingface" / "hub")
|
| 38 |
+
return roots
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _resolve_from_roots(filename: str) -> tuple[Path | None, str | None]:
|
| 42 |
+
patterns = [
|
| 43 |
+
filename,
|
| 44 |
+
filename.lower(),
|
| 45 |
+
filename.upper(),
|
| 46 |
+
"*MiniCPM5-1B*Q4_K_M*.gguf",
|
| 47 |
+
"*minicpm5-1b*Q4_K_M*.gguf",
|
| 48 |
+
"*MiniCPM5-1B*.gguf",
|
| 49 |
+
"*minicpm5-1b*.gguf",
|
| 50 |
+
]
|
| 51 |
+
for root in _candidate_roots():
|
| 52 |
+
if not root.exists():
|
| 53 |
+
continue
|
| 54 |
+
for pattern in patterns:
|
| 55 |
+
for candidate in root.rglob(pattern):
|
| 56 |
+
if candidate.is_file():
|
| 57 |
+
return candidate, f"local-cache:{root}"
|
| 58 |
+
return None, None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def resolve_model_path(*, model_id: str = DEFAULT_MODEL_ID, repo_id: str = DEFAULT_MODEL_REPO_ID, filename: str = DEFAULT_MODEL_FILENAME, env_var: str = "P1_MODEL_PATH") -> LoadedModel:
|
| 62 |
+
explicit = os.environ.get(env_var, "").strip()
|
| 63 |
+
if explicit:
|
| 64 |
+
path = Path(explicit).expanduser()
|
| 65 |
+
if path.exists():
|
| 66 |
+
return LoadedModel(model_id=model_id, model_path=path, source=f"env:{env_var}")
|
| 67 |
+
raise FileNotFoundError(f"{env_var} points to missing model path: {path}")
|
| 68 |
+
|
| 69 |
+
cached, source = _resolve_from_roots(filename)
|
| 70 |
+
if cached is not None:
|
| 71 |
+
return LoadedModel(model_id=model_id, model_path=cached, source=source or "local-cache")
|
| 72 |
+
|
| 73 |
+
allow_download = os.environ.get("P1_ALLOW_MODEL_DOWNLOAD", "1").strip().lower() not in {"0", "false", "no"}
|
| 74 |
+
if not allow_download:
|
| 75 |
+
raise FileNotFoundError(
|
| 76 |
+
f"Missing model checkpoint for {model_id}. Set {env_var} or place {filename} in MODEL_CACHE_DIR."
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
from huggingface_hub import hf_hub_download
|
| 81 |
+
except Exception as exc: # pragma: no cover - exercised in environments without the dependency
|
| 82 |
+
raise RuntimeError(
|
| 83 |
+
f"Could not import huggingface_hub to download {model_id}; install huggingface_hub or mount the model locally."
|
| 84 |
+
) from exc
|
| 85 |
+
|
| 86 |
+
cache_dir = _candidate_roots()[0]
|
| 87 |
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 88 |
+
try:
|
| 89 |
+
downloaded = hf_hub_download(
|
| 90 |
+
repo_id=repo_id,
|
| 91 |
+
filename=filename,
|
| 92 |
+
local_dir=str(cache_dir),
|
| 93 |
+
local_dir_use_symlinks=False,
|
| 94 |
+
)
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
raise RuntimeError(
|
| 97 |
+
f"Failed to download {model_id} from {repo_id}/{filename}. Mount a local checkpoint or pre-download the model."
|
| 98 |
+
) from exc
|
| 99 |
+
|
| 100 |
+
downloaded_path = Path(downloaded)
|
| 101 |
+
if not downloaded_path.exists():
|
| 102 |
+
raise RuntimeError(f"Download for {model_id} completed but file is missing: {downloaded_path}")
|
| 103 |
+
return LoadedModel(model_id=model_id, model_path=downloaded_path, source=f"huggingface:{repo_id}")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@lru_cache(maxsize=2)
|
| 107 |
+
def load_llama(model_path: str, n_ctx: int = DEFAULT_MODEL_CONTEXT):
|
| 108 |
+
try:
|
| 109 |
+
from llama_cpp import Llama
|
| 110 |
+
except Exception as exc: # pragma: no cover - import is exercised in runtime smoke tests
|
| 111 |
+
raise RuntimeError(
|
| 112 |
+
"llama-cpp-python is required for P1 model inference; install it in the runtime environment."
|
| 113 |
+
) from exc
|
| 114 |
+
return Llama(model_path=model_path, n_ctx=n_ctx, verbose=False)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _extract_json_object(text: str) -> dict[str, Any]:
|
| 118 |
+
start = text.find("{")
|
| 119 |
+
end = text.rfind("}")
|
| 120 |
+
if start == -1 or end == -1 or end <= start:
|
| 121 |
+
raise RuntimeError("Model output did not contain a JSON object")
|
| 122 |
+
raw = text[start : end + 1]
|
| 123 |
+
try:
|
| 124 |
+
payload = json.loads(raw)
|
| 125 |
+
except Exception as exc:
|
| 126 |
+
raise RuntimeError(f"Failed to parse JSON from model output: {exc}") from exc
|
| 127 |
+
if not isinstance(payload, dict):
|
| 128 |
+
raise RuntimeError("Model output JSON must be an object")
|
| 129 |
+
return payload
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _require_text(payload: dict[str, Any], field: str) -> str:
|
| 133 |
+
value = payload.get(field)
|
| 134 |
+
if not isinstance(value, str):
|
| 135 |
+
raise RuntimeError(f"Model output missing required '{field}' field")
|
| 136 |
+
value = value.strip()
|
| 137 |
+
if not value:
|
| 138 |
+
raise RuntimeError(f"Model output field '{field}' was empty")
|
| 139 |
+
return value
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def generate_text_completion(
|
| 143 |
+
*,
|
| 144 |
+
llm,
|
| 145 |
+
model: LoadedModel,
|
| 146 |
+
system_prompt: str,
|
| 147 |
+
user_prompt: str,
|
| 148 |
+
temperature: float = 0.2,
|
| 149 |
+
max_tokens: int = 256,
|
| 150 |
+
) -> tuple[str, dict[str, Any]]:
|
| 151 |
+
started_at = time.perf_counter()
|
| 152 |
+
prompt = f"{system_prompt.strip()}\n\n{user_prompt.strip()}\n\n### Response\n"
|
| 153 |
+
if hasattr(llm, 'create_chat_completion'):
|
| 154 |
+
response = llm.create_chat_completion(
|
| 155 |
+
messages=[
|
| 156 |
+
{"role": "system", "content": system_prompt},
|
| 157 |
+
{"role": "user", "content": user_prompt},
|
| 158 |
+
],
|
| 159 |
+
temperature=temperature,
|
| 160 |
+
max_tokens=max_tokens,
|
| 161 |
+
)
|
| 162 |
+
message = str(response["choices"][0]["message"]["content"]).strip()
|
| 163 |
+
else:
|
| 164 |
+
response = llm.create_completion(prompt=prompt, temperature=temperature, max_tokens=max_tokens)
|
| 165 |
+
message = str(response["choices"][0].get("text", "")).strip()
|
| 166 |
+
usage = response.get("usage") or {}
|
| 167 |
+
generation_stats = {
|
| 168 |
+
"prompt_tokens": int(usage.get("prompt_tokens", 0) or 0),
|
| 169 |
+
"completion_tokens": int(usage.get("completion_tokens", 0) or 0),
|
| 170 |
+
"total_tokens": int(usage.get("total_tokens", 0) or 0),
|
| 171 |
+
"elapsed_ms": round((time.perf_counter() - started_at) * 1000.0, 2),
|
| 172 |
+
"backend": "llama-cpp-python",
|
| 173 |
+
"model_path": str(model.model_path),
|
| 174 |
+
"n_ctx": DEFAULT_MODEL_CONTEXT,
|
| 175 |
+
}
|
| 176 |
+
meta = {
|
| 177 |
+
"model_id": model.model_id,
|
| 178 |
+
"model_path": str(model.model_path),
|
| 179 |
+
"model_source": model.source,
|
| 180 |
+
"backend": model.backend,
|
| 181 |
+
"generation_stats": generation_stats,
|
| 182 |
+
}
|
| 183 |
+
return message, meta
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def generate_json_completion(
|
| 187 |
+
*,
|
| 188 |
+
llm,
|
| 189 |
+
model: LoadedModel,
|
| 190 |
+
system_prompt: str,
|
| 191 |
+
user_prompt: str,
|
| 192 |
+
temperature: float = 0.2,
|
| 193 |
+
max_tokens: int = 512,
|
| 194 |
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
| 195 |
+
started_at = time.perf_counter()
|
| 196 |
+
response = llm.create_chat_completion(
|
| 197 |
+
messages=[
|
| 198 |
+
{"role": "system", "content": system_prompt},
|
| 199 |
+
{"role": "user", "content": user_prompt},
|
| 200 |
+
],
|
| 201 |
+
temperature=temperature,
|
| 202 |
+
max_tokens=max_tokens,
|
| 203 |
+
)
|
| 204 |
+
message = response["choices"][0]["message"]["content"]
|
| 205 |
+
payload = _extract_json_object(message)
|
| 206 |
+
|
| 207 |
+
usage = response.get("usage") or {}
|
| 208 |
+
generation_stats = {
|
| 209 |
+
"prompt_tokens": int(usage.get("prompt_tokens", 0) or 0),
|
| 210 |
+
"completion_tokens": int(usage.get("completion_tokens", 0) or 0),
|
| 211 |
+
"total_tokens": int(usage.get("total_tokens", 0) or 0),
|
| 212 |
+
"elapsed_ms": round((time.perf_counter() - started_at) * 1000.0, 2),
|
| 213 |
+
"backend": "llama-cpp-python",
|
| 214 |
+
"model_path": str(model.model_path),
|
| 215 |
+
"n_ctx": DEFAULT_MODEL_CONTEXT,
|
| 216 |
+
}
|
| 217 |
+
meta = {
|
| 218 |
+
"model_id": model.model_id,
|
| 219 |
+
"model_path": str(model.model_path),
|
| 220 |
+
"model_source": model.source,
|
| 221 |
+
"backend": model.backend,
|
| 222 |
+
"generation_stats": generation_stats,
|
| 223 |
+
}
|
| 224 |
+
return payload, meta
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def validate_p1_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
| 228 |
+
triage = _require_text(payload, "triage")
|
| 229 |
+
summary = _require_text(payload, "summary")
|
| 230 |
+
qa = payload.get("qa")
|
| 231 |
+
if not isinstance(qa, list) or not qa:
|
| 232 |
+
raise RuntimeError("Model output must include a non-empty 'qa' list")
|
| 233 |
+
normalized_qa: list[dict[str, str]] = []
|
| 234 |
+
for idx, item in enumerate(qa, start=1):
|
| 235 |
+
if not isinstance(item, dict):
|
| 236 |
+
raise RuntimeError(f"qa[{idx}] must be an object")
|
| 237 |
+
question = _require_text(item, "question")
|
| 238 |
+
answer = _require_text(item, "answer")
|
| 239 |
+
citation = _require_text(item, "citation")
|
| 240 |
+
normalized_qa.append({"question": question, "answer": answer, "citation": citation})
|
| 241 |
+
citations = payload.get("citations")
|
| 242 |
+
if not isinstance(citations, list) or not citations:
|
| 243 |
+
raise RuntimeError("Model output must include a non-empty 'citations' list")
|
| 244 |
+
normalized_citations: list[dict[str, str]] = []
|
| 245 |
+
for idx, item in enumerate(citations, start=1):
|
| 246 |
+
if not isinstance(item, dict):
|
| 247 |
+
raise RuntimeError(f"citations[{idx}] must be an object")
|
| 248 |
+
question = _require_text(item, "question")
|
| 249 |
+
snippet = _require_text(item, "snippet")
|
| 250 |
+
normalized_citations.append({"question": question, "snippet": snippet})
|
| 251 |
+
payload = dict(payload)
|
| 252 |
+
payload["triage"] = triage
|
| 253 |
+
payload["summary"] = summary
|
| 254 |
+
payload["qa"] = normalized_qa
|
| 255 |
+
payload["citations"] = normalized_citations
|
| 256 |
+
return payload
|
app_kit/project.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Any, Callable
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
from .demo_packs import DemoPack, read_text_inputs
|
| 10 |
+
from .embedding import extract_keywords
|
| 11 |
+
from .logging_utils import setup_logging
|
| 12 |
+
from .model_runtime import DEFAULT_MODEL_ID, generate_text_completion, load_llama, resolve_model_path
|
| 13 |
+
from .storage import SQLiteStore
|
| 14 |
+
|
| 15 |
+
LOGGER = setup_logging("p1_elder_paperwork", stream=sys.stderr)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class ProjectSpec:
|
| 20 |
+
key: str
|
| 21 |
+
title: str
|
| 22 |
+
description: str
|
| 23 |
+
data_subdir: str
|
| 24 |
+
search_enabled: bool
|
| 25 |
+
inbox_label: str
|
| 26 |
+
processor: Callable[[DemoPack, SQLiteStore, Any], dict[str, Any]]
|
| 27 |
+
|
| 28 |
+
def run_pack(self, pack: DemoPack, store: SQLiteStore, config: Any) -> dict[str, Any]:
|
| 29 |
+
return self.processor(pack, store, config)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _base_result(
|
| 33 |
+
pack: DemoPack,
|
| 34 |
+
store: SQLiteStore,
|
| 35 |
+
project: str,
|
| 36 |
+
title: str,
|
| 37 |
+
primary_text: str,
|
| 38 |
+
payload: dict[str, Any],
|
| 39 |
+
search_text: str | None = None,
|
| 40 |
+
) -> dict[str, Any]:
|
| 41 |
+
record_id = store.store_record(project, pack.pack_id, title, primary_text, payload, status='ready')
|
| 42 |
+
store.store_embedding(record_id, project, search_text or primary_text, metadata={'pack_id': pack.pack_id})
|
| 43 |
+
return {'record_id': record_id, 'pack_id': pack.pack_id, 'project': project, **payload}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _first_input_kind(pack: DemoPack) -> str:
|
| 47 |
+
inputs = pack.manifest.get('inputs', [])
|
| 48 |
+
if isinstance(inputs, list) and inputs:
|
| 49 |
+
first = inputs[0]
|
| 50 |
+
if isinstance(first, dict):
|
| 51 |
+
kind = first.get('kind')
|
| 52 |
+
if isinstance(kind, str) and kind.strip():
|
| 53 |
+
return kind.strip()
|
| 54 |
+
return 'text'
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _build_user_prompt(text: str, excerpt: str, keywords: tuple[str, ...], pack: DemoPack) -> str:
|
| 58 |
+
questions = [
|
| 59 |
+
'What is this document about?',
|
| 60 |
+
'What action is requested?',
|
| 61 |
+
'Is there a deadline or date mentioned?',
|
| 62 |
+
'Is there an amount, phone number, or next step mentioned?',
|
| 63 |
+
]
|
| 64 |
+
return (
|
| 65 |
+
"You are an elder paperwork triage assistant. Answer using only the document content below. "
|
| 66 |
+
"Return strict JSON with these keys: triage, summary, qa, citations, ocr_preview, safety. "
|
| 67 |
+
"triage must be one of urgent, important, FYI, informational. "
|
| 68 |
+
"qa must be a list of four objects, each with question, answer, and citation fields. "
|
| 69 |
+
"citations must be a list of four objects, each with question and snippet fields. "
|
| 70 |
+
"safety must be an object with missing_info_policy and invented_values fields. "
|
| 71 |
+
"Do not invent facts; quote short source snippets for citations when possible.\n\n"
|
| 72 |
+
f"PACK_ID: {pack.pack_id}\n"
|
| 73 |
+
f"EXPECTED_SIGNALS: {json.dumps(pack.expected_signals, ensure_ascii=False)}\n"
|
| 74 |
+
f"DOCUMENT_KIND: {_first_input_kind(pack)}\n"
|
| 75 |
+
f"KEYWORDS: {', '.join(keywords) or 'none'}\n"
|
| 76 |
+
f"DOCUMENT_EXCERPT:\n{excerpt}\n\n"
|
| 77 |
+
f"DOCUMENT_TEXT:\n{text[:4000]}\n\n"
|
| 78 |
+
"QUESTIONS:\n"
|
| 79 |
+
+ "\n".join(f"{idx}. {question}" for idx, question in enumerate(questions, start=1))
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def processor_p1(pack: DemoPack, store: SQLiteStore, config: Any) -> dict[str, Any]:
|
| 84 |
+
text = read_text_inputs(pack).strip()
|
| 85 |
+
if not text:
|
| 86 |
+
raise RuntimeError("P1 requires document text for model inference; no readable text was found in the pack.")
|
| 87 |
+
|
| 88 |
+
excerpt = text.splitlines()[0].strip() if text.splitlines() else text[:240].strip()
|
| 89 |
+
keywords = tuple(extract_keywords(text))
|
| 90 |
+
model = resolve_model_path(model_id=DEFAULT_MODEL_ID)
|
| 91 |
+
llm = load_llama(str(model.model_path))
|
| 92 |
+
|
| 93 |
+
def _final_text(raw: str) -> str:
|
| 94 |
+
cleaned = raw.replace('</think>', '\n').replace('<think>', '\n')
|
| 95 |
+
parts = [part.strip() for part in cleaned.splitlines() if part.strip()]
|
| 96 |
+
return parts[-1] if parts else raw.strip()
|
| 97 |
+
|
| 98 |
+
def _normalize_triage(raw: str) -> str:
|
| 99 |
+
lowered = raw.lower()
|
| 100 |
+
if 'urgent' in lowered:
|
| 101 |
+
return 'urgent'
|
| 102 |
+
if 'important' in lowered:
|
| 103 |
+
return 'important'
|
| 104 |
+
if 'fyi' in lowered:
|
| 105 |
+
return 'FYI'
|
| 106 |
+
return 'informational'
|
| 107 |
+
|
| 108 |
+
questions = [
|
| 109 |
+
'What is this document about?',
|
| 110 |
+
'What action is requested?',
|
| 111 |
+
'Is there a deadline or date mentioned?',
|
| 112 |
+
'Is there an amount, phone number, or next step mentioned?',
|
| 113 |
+
]
|
| 114 |
+
source_snippet = excerpt if excerpt else text[:180].strip()
|
| 115 |
+
|
| 116 |
+
triage_prompt = (
|
| 117 |
+
"Classify the document into exactly one label: urgent, important, FYI, informational.\n"
|
| 118 |
+
"Rules:\n"
|
| 119 |
+
"- urgent: immediate danger, same-day emergency, urgent medical action.\n"
|
| 120 |
+
"- important: routine appointment notices, follow-up visits, insurance notices, medication lists, or forms needing action soon.\n"
|
| 121 |
+
"- FYI: optional informational notices.\n"
|
| 122 |
+
"- informational: archival or purely informational documents.\n"
|
| 123 |
+
"For routine follow-up appointment notices, the correct label is important.\n"
|
| 124 |
+
f"Document:\n{text[:3000]}\n\n"
|
| 125 |
+
"Return exactly one label."
|
| 126 |
+
)
|
| 127 |
+
triage_raw, triage_meta = generate_text_completion(
|
| 128 |
+
llm=llm,
|
| 129 |
+
model=model,
|
| 130 |
+
system_prompt='Return only the label.',
|
| 131 |
+
user_prompt=triage_prompt,
|
| 132 |
+
temperature=0.0,
|
| 133 |
+
max_tokens=512,
|
| 134 |
+
)
|
| 135 |
+
triage = _normalize_triage(_final_text(triage_raw))
|
| 136 |
+
|
| 137 |
+
summary_prompt = (
|
| 138 |
+
"Document text:\n"
|
| 139 |
+
f"{text[:3500]}\n\n"
|
| 140 |
+
"Write one concise sentence summarizing the document."
|
| 141 |
+
)
|
| 142 |
+
summary_raw, summary_meta = generate_text_completion(
|
| 143 |
+
llm=llm,
|
| 144 |
+
model=model,
|
| 145 |
+
system_prompt='Write one concise sentence only.',
|
| 146 |
+
user_prompt=summary_prompt,
|
| 147 |
+
temperature=0.0,
|
| 148 |
+
max_tokens=96,
|
| 149 |
+
)
|
| 150 |
+
summary = _final_text(summary_raw)
|
| 151 |
+
|
| 152 |
+
safety_raw, safety_meta = generate_text_completion(
|
| 153 |
+
llm=llm,
|
| 154 |
+
model=model,
|
| 155 |
+
system_prompt='Return one short clause only.',
|
| 156 |
+
user_prompt=(
|
| 157 |
+
"Based on the document below, state whether more information is needed in one short clause. "
|
| 158 |
+
"Use phrasing like 'missing info likely' or 'sufficient detail'.\n\n"
|
| 159 |
+
f"{text[:2200]}"
|
| 160 |
+
),
|
| 161 |
+
temperature=0.0,
|
| 162 |
+
max_tokens=24,
|
| 163 |
+
)
|
| 164 |
+
safety_note = _final_text(safety_raw)
|
| 165 |
+
|
| 166 |
+
qa_items: list[dict[str, str]] = []
|
| 167 |
+
qa_stats: list[dict[str, Any]] = []
|
| 168 |
+
for question in questions:
|
| 169 |
+
answer_raw, answer_meta = generate_text_completion(
|
| 170 |
+
llm=llm,
|
| 171 |
+
model=model,
|
| 172 |
+
system_prompt='Answer the question using only the document text.',
|
| 173 |
+
user_prompt=(
|
| 174 |
+
f"QUESTION: {question}\n\n"
|
| 175 |
+
f"DOCUMENT TEXT:\n{text[:3500]}\n\n"
|
| 176 |
+
"Return one short sentence only."
|
| 177 |
+
),
|
| 178 |
+
temperature=0.0,
|
| 179 |
+
max_tokens=96,
|
| 180 |
+
)
|
| 181 |
+
qa_items.append({'question': question, 'answer': _final_text(answer_raw), 'citation': source_snippet})
|
| 182 |
+
qa_stats.append(answer_meta['generation_stats'])
|
| 183 |
+
|
| 184 |
+
generation_stats = {
|
| 185 |
+
'triage': triage_meta['generation_stats'],
|
| 186 |
+
'summary': summary_meta['generation_stats'],
|
| 187 |
+
'safety': safety_meta['generation_stats'],
|
| 188 |
+
'qa': qa_stats,
|
| 189 |
+
}
|
| 190 |
+
inference_meta = {
|
| 191 |
+
'model_id': model.model_id,
|
| 192 |
+
'model_path': str(model.model_path),
|
| 193 |
+
'model_source': model.source,
|
| 194 |
+
'backend': model.backend,
|
| 195 |
+
'generation_stats': generation_stats,
|
| 196 |
+
}
|
| 197 |
+
payload = {
|
| 198 |
+
'triage': triage,
|
| 199 |
+
'summary': summary,
|
| 200 |
+
'qa': qa_items,
|
| 201 |
+
'citations': [{'question': question, 'snippet': source_snippet} for question in questions],
|
| 202 |
+
'ocr_preview': summary,
|
| 203 |
+
'ocr_text': text,
|
| 204 |
+
'safety': {'missing_info_policy': safety_note, 'invented_values': False},
|
| 205 |
+
'inbox_items': [
|
| 206 |
+
{
|
| 207 |
+
'record_id': 'pending',
|
| 208 |
+
'title': pack.pack_id,
|
| 209 |
+
'triage': triage,
|
| 210 |
+
'summary': summary,
|
| 211 |
+
'file_type': _first_input_kind(pack),
|
| 212 |
+
},
|
| 213 |
+
],
|
| 214 |
+
'expected_signals': pack.expected_signals,
|
| 215 |
+
'evidence': keywords,
|
| 216 |
+
'inference': inference_meta,
|
| 217 |
+
'model_id': inference_meta['model_id'],
|
| 218 |
+
'adapter_name': inference_meta['backend'],
|
| 219 |
+
'generation_stats': generation_stats,
|
| 220 |
+
'source_excerpt': source_snippet,
|
| 221 |
+
}
|
| 222 |
+
search_text = ' '.join([text, summary, triage, ' '.join(keywords)])
|
| 223 |
+
result = _base_result(pack, store, 'p1', f'P1: {pack.pack_id}', payload['summary'], payload, search_text)
|
| 224 |
+
result['record_ids'] = [result['record_id']]
|
| 225 |
+
result['documents'] = [payload]
|
| 226 |
+
result['triage'] = triage
|
| 227 |
+
result['summary'] = summary
|
| 228 |
+
result['qa'] = qa_items
|
| 229 |
+
result['citations'] = payload['citations']
|
| 230 |
+
result['ocr_preview'] = payload['ocr_preview']
|
| 231 |
+
result['ocr_text'] = payload['ocr_text']
|
| 232 |
+
result['safety'] = payload['safety']
|
| 233 |
+
result['inbox_items'] = payload['inbox_items']
|
| 234 |
+
|
| 235 |
+
LOGGER.info(
|
| 236 |
+
json.dumps(
|
| 237 |
+
{
|
| 238 |
+
'event': 'p1_model_inference',
|
| 239 |
+
'pack_id': pack.pack_id,
|
| 240 |
+
'model_id': inference_meta['model_id'],
|
| 241 |
+
'adapter_name': inference_meta['backend'],
|
| 242 |
+
'generation_stats': generation_stats,
|
| 243 |
+
'triage': triage,
|
| 244 |
+
},
|
| 245 |
+
ensure_ascii=False,
|
| 246 |
+
)
|
| 247 |
+
)
|
| 248 |
+
return result
|
app_kit/server.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
from .config import load_app_config
|
| 7 |
+
from .demo_packs import load_demo_pack
|
| 8 |
+
from .logging_utils import setup_logging
|
| 9 |
+
from .model_registry import load_model_registry
|
| 10 |
+
from .storage import SQLiteStore
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
THEME_CSS_PATH = Path(__file__).resolve().parents[1] / "assets" / "theme.css"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def build_index_page() -> str:
|
| 17 |
+
return """
|
| 18 |
+
<h1>P1 Elder Paperwork Co-Pilot</h1>
|
| 19 |
+
<p>Select the P1 project and click a load button to seed a bundled demo pack.</p>
|
| 20 |
+
<ul>
|
| 21 |
+
<li>P1: Elder Paperwork Co-Pilot</li>
|
| 22 |
+
</ul>
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def create_launcher():
|
| 27 |
+
import gradio as gr
|
| 28 |
+
|
| 29 |
+
root = Path(os.environ.get('APP_ROOT_DIR', Path.cwd())).resolve()
|
| 30 |
+
config = load_app_config('p1')
|
| 31 |
+
registry = load_model_registry(config.model_registry_path)
|
| 32 |
+
logger = setup_logging('app_kit.server')
|
| 33 |
+
logger.info('app kit server started: %s', list(registry.get('projects', {}).keys()))
|
| 34 |
+
store = SQLiteStore(config.sqlite_path, config.artifact_dir)
|
| 35 |
+
|
| 36 |
+
with gr.Blocks(title='P1 Elder Paperwork Co-Pilot', css_paths=THEME_CSS_PATH) as demo:
|
| 37 |
+
gr.HTML(build_index_page())
|
| 38 |
+
project = gr.Dropdown(choices=['p1'], value='p1', label='Project')
|
| 39 |
+
pack = gr.Textbox(label='Demo pack path', placeholder=str(root / 'data' / 'demo_packs' / 'p1_elder_paperwork'))
|
| 40 |
+
output = gr.JSON(label='Latest result')
|
| 41 |
+
status = gr.Textbox(label='Status')
|
| 42 |
+
|
| 43 |
+
def load_pack(path: str):
|
| 44 |
+
demo_pack = load_demo_pack(path)
|
| 45 |
+
return demo_pack.manifest, f'loaded {demo_pack.pack_id}'
|
| 46 |
+
|
| 47 |
+
def show_history(proj: str):
|
| 48 |
+
return store.history(proj)
|
| 49 |
+
|
| 50 |
+
load_button = gr.Button('Load demo pack')
|
| 51 |
+
history_button = gr.Button('Refresh history')
|
| 52 |
+
load_button.click(load_pack, inputs=[pack], outputs=[output, status])
|
| 53 |
+
history_button.click(show_history, inputs=[project], outputs=[output])
|
| 54 |
+
|
| 55 |
+
return demo
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def main() -> int:
|
| 59 |
+
launcher = create_launcher()
|
| 60 |
+
launcher.launch(
|
| 61 |
+
server_name=os.environ.get('GRADIO_SERVER_NAME', '0.0.0.0'),
|
| 62 |
+
server_port=int(os.environ.get('PORT', '7860')),
|
| 63 |
+
show_error=True,
|
| 64 |
+
share=False,
|
| 65 |
+
)
|
| 66 |
+
return 0
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == '__main__':
|
| 70 |
+
raise SystemExit(main())
|
app_kit/sponsor_policy.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from datetime import date
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
import yaml
|
| 11 |
+
except Exception: # pragma: no cover
|
| 12 |
+
yaml = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
DEFAULT_POLICY_RELATIVE_PATH = Path("configs/sponsor_model_policy.yaml")
|
| 16 |
+
DEFAULT_WAIVER_RELATIVE_PATH = Path("configs/sponsor_model_waiver.yaml")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass(frozen=True)
|
| 20 |
+
class SponsorRequirement:
|
| 21 |
+
scope: str
|
| 22 |
+
key: str
|
| 23 |
+
component: str
|
| 24 |
+
model_id: str
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass(frozen=True)
|
| 28 |
+
class SponsorMismatch:
|
| 29 |
+
scope: str
|
| 30 |
+
key: str
|
| 31 |
+
expected_component: str
|
| 32 |
+
expected_model_id: str
|
| 33 |
+
actual_component: str | None
|
| 34 |
+
actual_model_id: str | None
|
| 35 |
+
problem: str
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class SponsorWaiver:
|
| 40 |
+
path: Path
|
| 41 |
+
reason: str
|
| 42 |
+
date: str
|
| 43 |
+
approved_by: str
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass(frozen=True)
|
| 47 |
+
class SponsorPolicyCheckResult:
|
| 48 |
+
ok: bool
|
| 49 |
+
requirements: tuple[SponsorRequirement, ...]
|
| 50 |
+
mismatches: tuple[SponsorMismatch, ...]
|
| 51 |
+
waiver: SponsorWaiver | None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _load_data(path: Path) -> Any:
|
| 55 |
+
text = path.read_text(encoding="utf-8")
|
| 56 |
+
if path.suffix.lower() == ".json":
|
| 57 |
+
return json.loads(text)
|
| 58 |
+
if yaml is not None:
|
| 59 |
+
return yaml.safe_load(text)
|
| 60 |
+
return json.loads(text)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def load_policy(path: str | Path) -> dict[str, Any]:
|
| 64 |
+
path = Path(path)
|
| 65 |
+
data = _load_data(path)
|
| 66 |
+
if not isinstance(data, dict):
|
| 67 |
+
raise ValueError(f"sponsor policy must be a mapping, got {type(data)!r}")
|
| 68 |
+
return data
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def load_registry(path: str | Path) -> dict[str, Any]:
|
| 72 |
+
path = Path(path)
|
| 73 |
+
data = _load_data(path)
|
| 74 |
+
if not isinstance(data, dict):
|
| 75 |
+
raise ValueError(f"model registry must be a mapping, got {type(data)!r}")
|
| 76 |
+
return data
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _require_mapping(data: Any, *, label: str) -> dict[str, Any]:
|
| 80 |
+
if not isinstance(data, dict):
|
| 81 |
+
raise ValueError(f"{label} must be a mapping, got {type(data)!r}")
|
| 82 |
+
return data
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _require_model_spec(
|
| 86 |
+
*,
|
| 87 |
+
spec: Any,
|
| 88 |
+
label: str,
|
| 89 |
+
scope: str,
|
| 90 |
+
key: str,
|
| 91 |
+
component_default: str,
|
| 92 |
+
) -> SponsorRequirement:
|
| 93 |
+
spec = _require_mapping(spec, label=label)
|
| 94 |
+
component = str(spec.get("component", component_default))
|
| 95 |
+
model_id = spec.get("model_id")
|
| 96 |
+
if not component:
|
| 97 |
+
raise ValueError(f"{label}.component must be a non-empty string")
|
| 98 |
+
if not isinstance(model_id, str) or not model_id.strip():
|
| 99 |
+
raise ValueError(f"{label}.model_id must be a non-empty string")
|
| 100 |
+
return SponsorRequirement(scope=scope, key=key, component=component, model_id=model_id)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _iter_requirements(policy: dict[str, Any]) -> tuple[SponsorRequirement, ...]:
|
| 104 |
+
required = policy.get("required_models", policy)
|
| 105 |
+
required = _require_mapping(required, label="sponsor policy.required_models")
|
| 106 |
+
requirements: list[SponsorRequirement] = []
|
| 107 |
+
|
| 108 |
+
shared = required.get("shared", {})
|
| 109 |
+
shared = _require_mapping(shared, label="sponsor policy.required_models.shared")
|
| 110 |
+
for key, spec in shared.items():
|
| 111 |
+
requirements.append(
|
| 112 |
+
_require_model_spec(
|
| 113 |
+
spec=spec,
|
| 114 |
+
label=f"sponsor policy.required_models.shared.{key}",
|
| 115 |
+
scope="shared",
|
| 116 |
+
key=key,
|
| 117 |
+
component_default=key,
|
| 118 |
+
)
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
projects = required.get("projects", {})
|
| 122 |
+
projects = _require_mapping(projects, label="sponsor policy.required_models.projects")
|
| 123 |
+
for project_key, spec in projects.items():
|
| 124 |
+
spec = _require_mapping(spec, label=f"sponsor policy.required_models.projects.{project_key}")
|
| 125 |
+
if "model_id" in spec:
|
| 126 |
+
requirements.append(
|
| 127 |
+
_require_model_spec(
|
| 128 |
+
spec=spec,
|
| 129 |
+
label=f"sponsor policy.required_models.projects.{project_key}",
|
| 130 |
+
scope="projects",
|
| 131 |
+
key=project_key,
|
| 132 |
+
component_default="",
|
| 133 |
+
)
|
| 134 |
+
)
|
| 135 |
+
continue
|
| 136 |
+
|
| 137 |
+
for component_key, component_spec in spec.items():
|
| 138 |
+
if not isinstance(component_spec, dict):
|
| 139 |
+
raise ValueError(
|
| 140 |
+
f"sponsor policy.required_models.projects.{project_key}.{component_key} must be a mapping"
|
| 141 |
+
)
|
| 142 |
+
requirements.append(
|
| 143 |
+
_require_model_spec(
|
| 144 |
+
spec=component_spec,
|
| 145 |
+
label=f"sponsor policy.required_models.projects.{project_key}.{component_key}",
|
| 146 |
+
scope="projects",
|
| 147 |
+
key=project_key,
|
| 148 |
+
component_default=component_key,
|
| 149 |
+
)
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
return tuple(requirements)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _validate_waiver(path: Path, waiver_data: Any) -> SponsorWaiver:
|
| 156 |
+
data = _require_mapping(waiver_data, label="sponsor waiver")
|
| 157 |
+
reason = data.get("reason")
|
| 158 |
+
approved_by = data.get("approved_by")
|
| 159 |
+
waiver_date = data.get("date")
|
| 160 |
+
if not isinstance(reason, str) or not reason.strip():
|
| 161 |
+
raise ValueError("sponsor waiver.reason must be a non-empty string")
|
| 162 |
+
if not isinstance(approved_by, str) or not approved_by.strip():
|
| 163 |
+
raise ValueError("sponsor waiver.approved_by must be a non-empty string")
|
| 164 |
+
if not isinstance(waiver_date, str) or not waiver_date.strip():
|
| 165 |
+
raise ValueError("sponsor waiver.date must be a non-empty string")
|
| 166 |
+
try:
|
| 167 |
+
date.fromisoformat(waiver_date)
|
| 168 |
+
except ValueError as exc:
|
| 169 |
+
raise ValueError("sponsor waiver.date must use ISO format YYYY-MM-DD") from exc
|
| 170 |
+
return SponsorWaiver(path=path, reason=reason.strip(), date=waiver_date.strip(), approved_by=approved_by.strip())
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def load_waiver(path: str | Path | None) -> SponsorWaiver | None:
|
| 174 |
+
if path is None:
|
| 175 |
+
return None
|
| 176 |
+
path = Path(path)
|
| 177 |
+
if not path.exists():
|
| 178 |
+
return None
|
| 179 |
+
return _validate_waiver(path, _load_data(path))
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _registry_entry_for_requirement(registry: dict[str, Any], requirement: SponsorRequirement) -> dict[str, Any] | None:
|
| 183 |
+
if requirement.scope == "shared":
|
| 184 |
+
shared = registry.get("shared", {})
|
| 185 |
+
if not isinstance(shared, dict):
|
| 186 |
+
raise ValueError("model registry.shared must be a mapping")
|
| 187 |
+
entry = shared.get(requirement.key)
|
| 188 |
+
return entry if isinstance(entry, dict) else None
|
| 189 |
+
if requirement.scope == "projects":
|
| 190 |
+
projects = registry.get("projects", {})
|
| 191 |
+
if not isinstance(projects, dict):
|
| 192 |
+
raise ValueError("model registry.projects must be a mapping")
|
| 193 |
+
project = projects.get(requirement.key)
|
| 194 |
+
if not isinstance(project, dict):
|
| 195 |
+
return None
|
| 196 |
+
if "model_id" in project or "component" in project:
|
| 197 |
+
return project
|
| 198 |
+
entry = project.get(requirement.component)
|
| 199 |
+
return entry if isinstance(entry, dict) else None
|
| 200 |
+
raise ValueError(f"unsupported sponsor requirement scope: {requirement.scope!r}")
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def check_sponsor_policy(
|
| 204 |
+
model_registry_path: str | Path,
|
| 205 |
+
policy_path: str | Path,
|
| 206 |
+
waiver_path: str | Path | None = None,
|
| 207 |
+
) -> SponsorPolicyCheckResult:
|
| 208 |
+
registry = load_registry(model_registry_path)
|
| 209 |
+
policy = load_policy(policy_path)
|
| 210 |
+
requirements = _iter_requirements(policy)
|
| 211 |
+
waiver = load_waiver(waiver_path)
|
| 212 |
+
|
| 213 |
+
mismatches: list[SponsorMismatch] = []
|
| 214 |
+
for requirement in requirements:
|
| 215 |
+
entry = _registry_entry_for_requirement(registry, requirement)
|
| 216 |
+
if entry is None:
|
| 217 |
+
mismatches.append(
|
| 218 |
+
SponsorMismatch(
|
| 219 |
+
scope=requirement.scope,
|
| 220 |
+
key=requirement.key,
|
| 221 |
+
expected_component=requirement.component,
|
| 222 |
+
expected_model_id=requirement.model_id,
|
| 223 |
+
actual_component=None,
|
| 224 |
+
actual_model_id=None,
|
| 225 |
+
problem="missing registry entry",
|
| 226 |
+
)
|
| 227 |
+
)
|
| 228 |
+
continue
|
| 229 |
+
actual_component = entry.get("component")
|
| 230 |
+
actual_model_id = entry.get("model_id")
|
| 231 |
+
if actual_component != requirement.component or actual_model_id != requirement.model_id:
|
| 232 |
+
if actual_component != requirement.component and actual_model_id != requirement.model_id:
|
| 233 |
+
problem = "component and model_id mismatch"
|
| 234 |
+
elif actual_component != requirement.component:
|
| 235 |
+
problem = "component mismatch"
|
| 236 |
+
else:
|
| 237 |
+
problem = "model_id mismatch"
|
| 238 |
+
mismatches.append(
|
| 239 |
+
SponsorMismatch(
|
| 240 |
+
scope=requirement.scope,
|
| 241 |
+
key=requirement.key,
|
| 242 |
+
expected_component=requirement.component,
|
| 243 |
+
expected_model_id=requirement.model_id,
|
| 244 |
+
actual_component=actual_component if isinstance(actual_component, str) else None,
|
| 245 |
+
actual_model_id=actual_model_id if isinstance(actual_model_id, str) else None,
|
| 246 |
+
problem=problem,
|
| 247 |
+
)
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
ok = not mismatches or waiver is not None
|
| 251 |
+
return SponsorPolicyCheckResult(ok=ok, requirements=requirements, mismatches=tuple(mismatches), waiver=waiver)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def format_sponsor_policy_result(result: SponsorPolicyCheckResult) -> tuple[str, str, int]:
|
| 255 |
+
if result.mismatches and result.waiver is None:
|
| 256 |
+
lines = ["ERROR: sponsor model mismatch detected:"]
|
| 257 |
+
for mismatch in result.mismatches:
|
| 258 |
+
actual_component = mismatch.actual_component or "<missing>"
|
| 259 |
+
actual_model_id = mismatch.actual_model_id or "<missing>"
|
| 260 |
+
lines.append(
|
| 261 |
+
f"- {mismatch.scope}.{mismatch.key}: expected component={mismatch.expected_component!r}, "
|
| 262 |
+
f"model_id={mismatch.expected_model_id!r}; got component={actual_component!r}, "
|
| 263 |
+
f"model_id={actual_model_id!r} ({mismatch.problem})"
|
| 264 |
+
)
|
| 265 |
+
lines.append("Fix configs/model_registry.yaml or provide configs/sponsor_model_waiver.yaml to justify the exception.")
|
| 266 |
+
return ("", "\n".join(lines), 1)
|
| 267 |
+
|
| 268 |
+
if result.mismatches and result.waiver is not None:
|
| 269 |
+
warning_lines = [
|
| 270 |
+
"WARNING: sponsor model mismatch waived.",
|
| 271 |
+
f"- waiver file: {result.waiver.path}",
|
| 272 |
+
f"- approved_by: {result.waiver.approved_by}",
|
| 273 |
+
f"- date: {result.waiver.date}",
|
| 274 |
+
f"- reason: {result.waiver.reason}",
|
| 275 |
+
]
|
| 276 |
+
stdout = "Sponsor model policy check passed with waiver."
|
| 277 |
+
return (stdout, "\n".join(warning_lines), 0)
|
| 278 |
+
|
| 279 |
+
stdout = f"Sponsor model policy check passed: {len(result.requirements)} required sponsor model(s) aligned."
|
| 280 |
+
return (stdout, "", 0)
|
app_kit/storage.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import sqlite3
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
import shutil
|
| 10 |
+
import uuid
|
| 11 |
+
|
| 12 |
+
from .embedding import SimpleEmbeddingIndex
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
DEFAULT_DB_PATH = Path('app_data.sqlite3')
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def init_db(db_path: str | Path = DEFAULT_DB_PATH, artifact_dir: str | Path | None = None) -> None:
|
| 19 |
+
db_path = Path(db_path)
|
| 20 |
+
artifact_dir = Path(artifact_dir) if artifact_dir is not None else db_path.parent / 'artifacts'
|
| 21 |
+
store = SQLiteStore(db_path, artifact_dir)
|
| 22 |
+
store.close()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def reset_db(db_path: str | Path = DEFAULT_DB_PATH, artifact_dir: str | Path | None = None) -> None:
|
| 26 |
+
db_path = Path(db_path)
|
| 27 |
+
if db_path.exists():
|
| 28 |
+
db_path.unlink()
|
| 29 |
+
init_db(db_path, artifact_dir)
|
| 30 |
+
|
| 31 |
+
SCHEMA = """
|
| 32 |
+
CREATE TABLE IF NOT EXISTS artifacts (
|
| 33 |
+
id TEXT PRIMARY KEY,
|
| 34 |
+
project TEXT NOT NULL,
|
| 35 |
+
pack_id TEXT NOT NULL,
|
| 36 |
+
type TEXT NOT NULL,
|
| 37 |
+
path TEXT NOT NULL,
|
| 38 |
+
created_at TEXT NOT NULL,
|
| 39 |
+
metadata_json TEXT NOT NULL
|
| 40 |
+
);
|
| 41 |
+
CREATE TABLE IF NOT EXISTS records (
|
| 42 |
+
id TEXT PRIMARY KEY,
|
| 43 |
+
project TEXT NOT NULL,
|
| 44 |
+
pack_id TEXT NOT NULL,
|
| 45 |
+
title TEXT NOT NULL,
|
| 46 |
+
primary_text TEXT NOT NULL,
|
| 47 |
+
json_blob TEXT NOT NULL,
|
| 48 |
+
status TEXT NOT NULL,
|
| 49 |
+
created_at TEXT NOT NULL
|
| 50 |
+
);
|
| 51 |
+
CREATE TABLE IF NOT EXISTS embeddings (
|
| 52 |
+
record_id TEXT PRIMARY KEY,
|
| 53 |
+
project TEXT NOT NULL,
|
| 54 |
+
vector_json TEXT NOT NULL,
|
| 55 |
+
metadata_json TEXT NOT NULL,
|
| 56 |
+
created_at TEXT NOT NULL
|
| 57 |
+
);
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass
|
| 62 |
+
class StoredPackResult:
|
| 63 |
+
record_id: str
|
| 64 |
+
title: str
|
| 65 |
+
primary_text: str
|
| 66 |
+
json_blob: dict[str, Any]
|
| 67 |
+
status: str
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def utc_now() -> str:
|
| 71 |
+
return datetime.now(timezone.utc).isoformat()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class SQLiteStore:
|
| 75 |
+
def __init__(self, db_path: str | Path, artifact_dir: str | Path):
|
| 76 |
+
self.db_path = Path(db_path)
|
| 77 |
+
self.artifact_dir = Path(artifact_dir)
|
| 78 |
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
| 79 |
+
self.artifact_dir.mkdir(parents=True, exist_ok=True)
|
| 80 |
+
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
| 81 |
+
self._conn.row_factory = sqlite3.Row
|
| 82 |
+
self._conn.executescript(SCHEMA)
|
| 83 |
+
self._conn.commit()
|
| 84 |
+
|
| 85 |
+
def close(self) -> None:
|
| 86 |
+
self._conn.close()
|
| 87 |
+
|
| 88 |
+
def store_artifact(self, project: str, pack_id: str, source_path: Path, kind: str, metadata: dict[str, Any] | None = None) -> str:
|
| 89 |
+
artifact_id = str(uuid.uuid4())
|
| 90 |
+
dest = self.artifact_dir / f'{artifact_id}{source_path.suffix or ".bin"}'
|
| 91 |
+
shutil.copy2(source_path, dest)
|
| 92 |
+
self._conn.execute(
|
| 93 |
+
'INSERT INTO artifacts VALUES (?, ?, ?, ?, ?, ?, ?)',
|
| 94 |
+
(artifact_id, project, pack_id, kind, str(dest), utc_now(), json.dumps(metadata or {}, ensure_ascii=False)),
|
| 95 |
+
)
|
| 96 |
+
self._conn.commit()
|
| 97 |
+
return artifact_id
|
| 98 |
+
|
| 99 |
+
def store_record(self, project: str, pack_id: str, title: str, primary_text: str, payload: dict[str, Any], status: str = 'stored', record_id: str | None = None) -> str:
|
| 100 |
+
record_id = record_id or str(uuid.uuid4())
|
| 101 |
+
self._conn.execute(
|
| 102 |
+
'INSERT OR REPLACE INTO records VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
| 103 |
+
(record_id, project, pack_id, title, primary_text, json.dumps(payload, ensure_ascii=False), status, utc_now()),
|
| 104 |
+
)
|
| 105 |
+
self._conn.commit()
|
| 106 |
+
return record_id
|
| 107 |
+
|
| 108 |
+
def store_embedding(self, record_id: str, project: str, text: str, metadata: dict[str, Any] | None = None) -> None:
|
| 109 |
+
vec = SimpleEmbeddingIndex()
|
| 110 |
+
vec.add(record_id, text)
|
| 111 |
+
vector_json = json.dumps({token: count for token, count in vec.entries[record_id].items()}, ensure_ascii=False)
|
| 112 |
+
self._conn.execute(
|
| 113 |
+
'INSERT OR REPLACE INTO embeddings VALUES (?, ?, ?, ?, ?)',
|
| 114 |
+
(record_id, project, vector_json, json.dumps(metadata or {}, ensure_ascii=False), utc_now()),
|
| 115 |
+
)
|
| 116 |
+
self._conn.commit()
|
| 117 |
+
|
| 118 |
+
def _embedding_index(self, project: str) -> SimpleEmbeddingIndex:
|
| 119 |
+
index = SimpleEmbeddingIndex()
|
| 120 |
+
rows = self._conn.execute('SELECT record_id, vector_json FROM embeddings WHERE project = ?', (project,)).fetchall()
|
| 121 |
+
for row in rows:
|
| 122 |
+
index.entries[row['record_id']] = __import__('collections').Counter(json.loads(row['vector_json']))
|
| 123 |
+
return index
|
| 124 |
+
|
| 125 |
+
def search_records(self, project: str, query: str, limit: int = 5) -> list[dict[str, Any]]:
|
| 126 |
+
index = self._embedding_index(project)
|
| 127 |
+
scored = index.search(query, limit=limit)
|
| 128 |
+
if not scored:
|
| 129 |
+
return []
|
| 130 |
+
ids = [record_id for record_id, score in scored if score > 0]
|
| 131 |
+
if not ids:
|
| 132 |
+
ids = [record_id for record_id, _ in scored]
|
| 133 |
+
out = []
|
| 134 |
+
for record_id in ids:
|
| 135 |
+
row = self._conn.execute('SELECT * FROM records WHERE id = ?', (record_id,)).fetchone()
|
| 136 |
+
if row:
|
| 137 |
+
out.append(dict(row))
|
| 138 |
+
return out[:limit]
|
| 139 |
+
|
| 140 |
+
def list_records(self, project: str) -> list[dict[str, Any]]:
|
| 141 |
+
rows = self._conn.execute('SELECT * FROM records WHERE project = ? ORDER BY created_at DESC', (project,)).fetchall()
|
| 142 |
+
return [dict(row) for row in rows]
|
| 143 |
+
|
| 144 |
+
def get_record(self, record_id: str) -> dict[str, Any] | None:
|
| 145 |
+
row = self._conn.execute('SELECT * FROM records WHERE id = ?', (record_id,)).fetchone()
|
| 146 |
+
return dict(row) if row else None
|
| 147 |
+
|
| 148 |
+
def inbox(self, project: str) -> list[dict[str, Any]]:
|
| 149 |
+
return self.list_records(project)
|
| 150 |
+
|
| 151 |
+
def history(self, project: str) -> list[dict[str, Any]]:
|
| 152 |
+
return self.list_records(project)
|
app_kit/tracing.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import uuid
|
| 5 |
+
from dataclasses import asdict, is_dataclass
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
_CANONICAL_INPUT_KEYS = (
|
| 11 |
+
'inputs',
|
| 12 |
+
'input',
|
| 13 |
+
'pack',
|
| 14 |
+
'pack_id',
|
| 15 |
+
'pack_name',
|
| 16 |
+
'pack_path',
|
| 17 |
+
'project',
|
| 18 |
+
'scenario_id',
|
| 19 |
+
'scenario_count',
|
| 20 |
+
'query',
|
| 21 |
+
'prompt',
|
| 22 |
+
'transcript',
|
| 23 |
+
'text',
|
| 24 |
+
)
|
| 25 |
+
_MODEL_HINT_KEYS = (
|
| 26 |
+
'model_name',
|
| 27 |
+
'base_model_id',
|
| 28 |
+
'base_model',
|
| 29 |
+
'model_id',
|
| 30 |
+
'adapter_name',
|
| 31 |
+
'loaded_from',
|
| 32 |
+
'adapter_path',
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
def utc_now() -> str:
|
| 36 |
+
return datetime.now(timezone.utc).isoformat(timespec='seconds')
|
| 37 |
+
|
| 38 |
+
def _json_safe(value: Any) -> Any:
|
| 39 |
+
if is_dataclass(value):
|
| 40 |
+
return _json_safe(asdict(value))
|
| 41 |
+
if isinstance(value, Path):
|
| 42 |
+
return str(value)
|
| 43 |
+
if isinstance(value, dict):
|
| 44 |
+
return {str(key): _json_safe(item) for key, item in value.items()}
|
| 45 |
+
if isinstance(value, (list, tuple)):
|
| 46 |
+
return [_json_safe(item) for item in value]
|
| 47 |
+
return value
|
| 48 |
+
|
| 49 |
+
def _nonempty(value: Any) -> bool:
|
| 50 |
+
return value not in (None, '', [], {}, ())
|
| 51 |
+
|
| 52 |
+
def _infer_input_payload(payload: dict[str, Any]) -> Any:
|
| 53 |
+
existing_inputs = payload.get('inputs')
|
| 54 |
+
if _nonempty(existing_inputs):
|
| 55 |
+
return existing_inputs
|
| 56 |
+
inputs: dict[str, Any] = {}
|
| 57 |
+
for key in _CANONICAL_INPUT_KEYS:
|
| 58 |
+
if key in payload and key != 'inputs' and _nonempty(payload[key]):
|
| 59 |
+
inputs[key] = payload[key]
|
| 60 |
+
if inputs:
|
| 61 |
+
return inputs
|
| 62 |
+
for key in ('project', 'pack_id', 'pack_name', 'pack_path', 'pack'):
|
| 63 |
+
if key in payload and _nonempty(payload[key]):
|
| 64 |
+
inputs[key] = payload[key]
|
| 65 |
+
return inputs
|
| 66 |
+
|
| 67 |
+
def _infer_model_name(value: Any) -> str | None:
|
| 68 |
+
if isinstance(value, dict):
|
| 69 |
+
for key in _MODEL_HINT_KEYS:
|
| 70 |
+
candidate = value.get(key)
|
| 71 |
+
if isinstance(candidate, str) and candidate.strip():
|
| 72 |
+
return candidate.strip()
|
| 73 |
+
for item in value.values():
|
| 74 |
+
candidate = _infer_model_name(item)
|
| 75 |
+
if candidate:
|
| 76 |
+
return candidate
|
| 77 |
+
elif isinstance(value, list):
|
| 78 |
+
for item in value:
|
| 79 |
+
candidate = _infer_model_name(item)
|
| 80 |
+
if candidate:
|
| 81 |
+
return candidate
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
def canonicalize_trace_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
| 85 |
+
normalized = _json_safe(payload)
|
| 86 |
+
timestamp = normalized.get('timestamp') or normalized.get('finished_at') or normalized.get('started_at') or utc_now()
|
| 87 |
+
parsed_outputs = normalized.get('parsed_outputs')
|
| 88 |
+
if not _nonempty(parsed_outputs):
|
| 89 |
+
parsed_outputs = normalized.get('result')
|
| 90 |
+
if not _nonempty(parsed_outputs):
|
| 91 |
+
parsed_outputs = normalized.get('results')
|
| 92 |
+
if not _nonempty(parsed_outputs):
|
| 93 |
+
parsed_outputs = normalized.get('output')
|
| 94 |
+
if not _nonempty(parsed_outputs):
|
| 95 |
+
parsed_outputs = {}
|
| 96 |
+
model_name = normalized.get('model_name')
|
| 97 |
+
if not _nonempty(model_name):
|
| 98 |
+
model_name = _infer_model_name(parsed_outputs) or _infer_model_name(normalized)
|
| 99 |
+
if not _nonempty(model_name):
|
| 100 |
+
model_name = f"{normalized.get('project') or normalized.get('kind') or 'unknown'}:rule-based"
|
| 101 |
+
normalized['timestamp'] = str(timestamp)
|
| 102 |
+
normalized['inputs'] = _infer_input_payload(normalized)
|
| 103 |
+
normalized['parsed_outputs'] = parsed_outputs
|
| 104 |
+
normalized['model_name'] = str(model_name)
|
| 105 |
+
return normalized
|
| 106 |
+
|
| 107 |
+
def write_trace_artifact(artifact_dir: str | Path, payload: dict[str, Any]) -> Path:
|
| 108 |
+
artifact_dir = Path(artifact_dir)
|
| 109 |
+
trace_dir = artifact_dir / 'traces'
|
| 110 |
+
trace_dir.mkdir(parents=True, exist_ok=True)
|
| 111 |
+
run_id = str(payload.get('run_id') or uuid.uuid4().hex)
|
| 112 |
+
kind = str(payload.get('kind', 'trace')).strip().replace('/', '_').replace(' ', '_') or 'trace'
|
| 113 |
+
trace_path = trace_dir / f'{kind}-{run_id}.json'
|
| 114 |
+
normalized = canonicalize_trace_payload(payload)
|
| 115 |
+
normalized['run_id'] = run_id
|
| 116 |
+
normalized['trace_path'] = str(trace_path)
|
| 117 |
+
trace_path.write_text(json.dumps(normalized, indent=2, ensure_ascii=False, sort_keys=True, default=str), encoding='utf-8')
|
| 118 |
+
return trace_path
|
assets/theme.css
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* P1 Elder Care Document Assistant — calming, accessible dark UI */
|
| 2 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap');
|
| 3 |
+
|
| 4 |
+
:root {
|
| 5 |
+
color-scheme: dark;
|
| 6 |
+
--p1-bg-deep: #0c1220;
|
| 7 |
+
--p1-bg-card: rgba(15, 25, 50, 0.65);
|
| 8 |
+
--p1-bg-surface: rgba(30, 45, 80, 0.35);
|
| 9 |
+
--p1-text: #f8fafc;
|
| 10 |
+
--p1-text-muted: #94a3c8;
|
| 11 |
+
--p1-accent: #fbbf24;
|
| 12 |
+
--p1-accent-hover: #f59e0b;
|
| 13 |
+
--p1-accent-glow: rgba(251, 191, 36, 0.15);
|
| 14 |
+
--p1-success: #34d399;
|
| 15 |
+
--p1-warning: #fbbf24;
|
| 16 |
+
--p1-danger: #f87171;
|
| 17 |
+
--p1-info: #60a5fa;
|
| 18 |
+
--p1-border: rgba(96, 165, 250, 0.18);
|
| 19 |
+
--p1-radius: 16px;
|
| 20 |
+
--p1-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/* Base container */
|
| 24 |
+
.gradio-container {
|
| 25 |
+
background:
|
| 26 |
+
radial-gradient(ellipse at top left, rgba(59, 130, 246, 0.08) 0%, transparent 50%),
|
| 27 |
+
radial-gradient(ellipse at bottom right, rgba(251, 191, 36, 0.05) 0%, transparent 50%),
|
| 28 |
+
linear-gradient(180deg, #0f172a 0%, #0c1220 40%, #020617 100%);
|
| 29 |
+
color: var(--p1-text);
|
| 30 |
+
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
| 31 |
+
font-size: 16px;
|
| 32 |
+
line-height: 1.6;
|
| 33 |
+
min-height: 100vh;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* Typography */
|
| 37 |
+
.gradio-container .prose,
|
| 38 |
+
.gradio-container p,
|
| 39 |
+
.gradio-container span,
|
| 40 |
+
.gradio-container label {
|
| 41 |
+
color: var(--p1-text);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.gradio-container h1 {
|
| 45 |
+
font-family: 'Outfit', system-ui, sans-serif;
|
| 46 |
+
color: var(--p1-accent);
|
| 47 |
+
font-size: 2rem;
|
| 48 |
+
font-weight: 700;
|
| 49 |
+
letter-spacing: -0.01em;
|
| 50 |
+
text-shadow: 0 0 40px var(--p1-accent-glow);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.gradio-container h2,
|
| 54 |
+
.gradio-container h3 {
|
| 55 |
+
font-family: 'Outfit', system-ui, sans-serif;
|
| 56 |
+
color: var(--p1-text);
|
| 57 |
+
font-weight: 600;
|
| 58 |
+
letter-spacing: -0.005em;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.gradio-container h4 {
|
| 62 |
+
color: var(--p1-text-muted);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/* Input fields */
|
| 66 |
+
.gradio-container input,
|
| 67 |
+
.gradio-container textarea,
|
| 68 |
+
.gradio-container select {
|
| 69 |
+
background: var(--p1-bg-card);
|
| 70 |
+
color: var(--p1-text);
|
| 71 |
+
border: 1px solid var(--p1-border);
|
| 72 |
+
border-radius: var(--p1-radius);
|
| 73 |
+
backdrop-filter: blur(8px);
|
| 74 |
+
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.gradio-container input:focus,
|
| 78 |
+
.gradio-container textarea:focus {
|
| 79 |
+
border-color: var(--p1-accent);
|
| 80 |
+
box-shadow: 0 0 0 3px var(--p1-accent-glow);
|
| 81 |
+
outline: none;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* Buttons */
|
| 85 |
+
.gradio-container button {
|
| 86 |
+
border-radius: 999px;
|
| 87 |
+
font-weight: 600;
|
| 88 |
+
font-family: 'Inter', system-ui, sans-serif;
|
| 89 |
+
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 90 |
+
letter-spacing: 0.01em;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.gradio-container button.primary,
|
| 94 |
+
.gradio-container button.lg.primary {
|
| 95 |
+
background: linear-gradient(135deg, var(--p1-accent) 0%, #f97316 100%);
|
| 96 |
+
color: #111827;
|
| 97 |
+
font-weight: 700;
|
| 98 |
+
box-shadow: 0 4px 16px rgba(251, 191, 36, 0.25);
|
| 99 |
+
border: none;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.gradio-container button.primary:hover {
|
| 103 |
+
transform: translateY(-1px);
|
| 104 |
+
box-shadow: 0 6px 24px rgba(251, 191, 36, 0.35);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.gradio-container button.secondary {
|
| 108 |
+
background: var(--p1-bg-card);
|
| 109 |
+
color: var(--p1-text);
|
| 110 |
+
border: 1px solid var(--p1-border);
|
| 111 |
+
backdrop-filter: blur(8px);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.gradio-container button.secondary:hover {
|
| 115 |
+
border-color: var(--p1-accent);
|
| 116 |
+
background: var(--p1-bg-surface);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* Upload area */
|
| 120 |
+
.gradio-container .upload-area {
|
| 121 |
+
border: 2px dashed rgba(96, 165, 250, 0.3);
|
| 122 |
+
border-radius: 20px;
|
| 123 |
+
background: var(--p1-bg-card);
|
| 124 |
+
backdrop-filter: blur(12px);
|
| 125 |
+
transition: border-color 0.3s ease, background 0.3s ease;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.gradio-container .upload-area:hover {
|
| 129 |
+
border-color: var(--p1-accent);
|
| 130 |
+
background: rgba(251, 191, 36, 0.05);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/* Result cards */
|
| 134 |
+
.gradio-container .result-card,
|
| 135 |
+
.gradio-container .history-card,
|
| 136 |
+
.gradio-container .status-box {
|
| 137 |
+
background: var(--p1-bg-card);
|
| 138 |
+
border: 1px solid var(--p1-border);
|
| 139 |
+
border-radius: var(--p1-radius);
|
| 140 |
+
padding: 1.25rem;
|
| 141 |
+
backdrop-filter: blur(12px);
|
| 142 |
+
box-shadow: var(--p1-shadow);
|
| 143 |
+
color: var(--p1-text) !important;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.gradio-container .result-card p,
|
| 147 |
+
.gradio-container .history-card p,
|
| 148 |
+
.gradio-container .status-box p,
|
| 149 |
+
.gradio-container .result-card span,
|
| 150 |
+
.gradio-container .history-card span {
|
| 151 |
+
color: var(--p1-text) !important;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
.gradio-container .result-card h3 {
|
| 156 |
+
margin-top: 0;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/* Search box */
|
| 160 |
+
.gradio-container .search-box input {
|
| 161 |
+
padding-left: 1rem;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/* Accordion */
|
| 165 |
+
.gradio-container .accordion {
|
| 166 |
+
background: var(--p1-bg-surface);
|
| 167 |
+
border: 1px solid var(--p1-border);
|
| 168 |
+
border-radius: var(--p1-radius);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
/* Markdown formatting within results */
|
| 172 |
+
.gradio-container .markdown-text blockquote {
|
| 173 |
+
border-left: 3px solid var(--p1-accent);
|
| 174 |
+
padding-left: 1rem;
|
| 175 |
+
margin-left: 0;
|
| 176 |
+
color: var(--p1-text-muted);
|
| 177 |
+
font-style: italic;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.gradio-container .markdown-text code {
|
| 181 |
+
background: rgba(96, 165, 250, 0.12);
|
| 182 |
+
color: var(--p1-info);
|
| 183 |
+
padding: 0.15rem 0.4rem;
|
| 184 |
+
border-radius: 6px;
|
| 185 |
+
font-size: 0.85em;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.gradio-container .markdown-text hr {
|
| 189 |
+
border-color: var(--p1-border);
|
| 190 |
+
margin: 1rem 0;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/* JSON panel (kept for dev accordion) */
|
| 194 |
+
.gradio-container .json-holder {
|
| 195 |
+
background: var(--p1-bg-card);
|
| 196 |
+
border: 1px solid var(--p1-border);
|
| 197 |
+
border-radius: var(--p1-radius);
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/* Scrollbar */
|
| 201 |
+
.gradio-container ::-webkit-scrollbar {
|
| 202 |
+
width: 6px;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.gradio-container ::-webkit-scrollbar-track {
|
| 206 |
+
background: transparent;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.gradio-container ::-webkit-scrollbar-thumb {
|
| 210 |
+
background: rgba(96, 165, 250, 0.25);
|
| 211 |
+
border-radius: 4px;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
/* Footer */
|
| 215 |
+
.gradio-container footer {
|
| 216 |
+
opacity: 0.5;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
/* Responsive */
|
| 220 |
+
@media (max-width: 768px) {
|
| 221 |
+
.gradio-container h1 {
|
| 222 |
+
font-size: 1.5rem;
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
/* Micro-animations */
|
| 227 |
+
@keyframes fadeIn {
|
| 228 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 229 |
+
to { opacity: 1; transform: translateY(0); }
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.gradio-container .result-card,
|
| 233 |
+
.gradio-container .history-card {
|
| 234 |
+
animation: fadeIn 0.4s ease-out;
|
| 235 |
+
}
|
configs/model_registry.yaml
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
shared:
|
| 2 |
+
embedder:
|
| 3 |
+
component: embedder
|
| 4 |
+
model_id: sentence-transformers/all-MiniLM-L6-v2
|
| 5 |
+
license: Apache-2.0
|
| 6 |
+
usage_notes: Offline embedding baseline for search and retrieval helpers.
|
| 7 |
+
runtime: heuristic+hf
|
| 8 |
+
backend: sentence-transformers
|
| 9 |
+
local_fallback: builtin-text-similarity
|
| 10 |
+
summary_llm:
|
| 11 |
+
component: summary_llm
|
| 12 |
+
model_id: openbmb/MiniCPM-5-1B
|
| 13 |
+
license: MiniCPM-custom
|
| 14 |
+
usage_notes: Plain-language summaries and triage narration for elder paperwork.
|
| 15 |
+
runtime: heuristic+hf
|
| 16 |
+
backend: transformers
|
| 17 |
+
local_fallback: deterministic-template
|
| 18 |
+
projects:
|
| 19 |
+
p1:
|
| 20 |
+
ocr_vlm:
|
| 21 |
+
component: ocr_vlm
|
| 22 |
+
model_id: openbmb/MiniCPM-V-4_6
|
| 23 |
+
params: "~1.3B"
|
| 24 |
+
sponsor: MiniCPM
|
| 25 |
+
license: MiniCPM-custom
|
| 26 |
+
usage_notes: OCR and layout understanding for scanned elder paperwork.
|
| 27 |
+
runtime: heuristic+hf
|
| 28 |
+
backend: transformers
|
| 29 |
+
local_fallback: deterministic-ocr
|
| 30 |
+
triage_llm:
|
| 31 |
+
component: triage_llm
|
| 32 |
+
model_id: openbmb/MiniCPM-5-1B
|
| 33 |
+
params: "1B"
|
| 34 |
+
sponsor: MiniCPM
|
| 35 |
+
license: MiniCPM-custom
|
| 36 |
+
usage_notes: Triage labels and plain-language summaries for elder paperwork packets.
|
| 37 |
+
runtime: heuristic+hf
|
| 38 |
+
backend: transformers
|
| 39 |
+
local_fallback: deterministic-template
|
| 40 |
+
table_parser:
|
| 41 |
+
component: table_parser
|
| 42 |
+
model_id: nvidia/NVIDIA-Nemotron-Parse-v1.1
|
| 43 |
+
params: "<1B"
|
| 44 |
+
sponsor: NeMoTRON
|
| 45 |
+
license: NVIDIA
|
| 46 |
+
usage_notes: Structured extraction of tables and form fields from bills and notices.
|
| 47 |
+
runtime: heuristic+hf
|
| 48 |
+
backend: transformers
|
| 49 |
+
local_fallback: deterministic-table-parser
|
| 50 |
+
asr:
|
| 51 |
+
component: asr
|
| 52 |
+
model_id: CohereLabs/cohere-transcribe-03-2026
|
| 53 |
+
params: "2B"
|
| 54 |
+
sponsor: CoExpression
|
| 55 |
+
license: proprietary
|
| 56 |
+
usage_notes: Voice-note transcription for caregiver follow-ups.
|
| 57 |
+
runtime: heuristic+hf
|
| 58 |
+
backend: transformers
|
| 59 |
+
local_fallback: offline-transcribe
|
configs/sponsor_model_policy.yaml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: 1
|
| 2 |
+
purpose: sponsor-model alignment gate for the ALL4 P1 elder paperwork repo
|
| 3 |
+
required_models:
|
| 4 |
+
projects:
|
| 5 |
+
p1:
|
| 6 |
+
ocr_vlm:
|
| 7 |
+
component: ocr_vlm
|
| 8 |
+
model_id: openbmb/MiniCPM-V-4_6
|
| 9 |
+
triage_llm:
|
| 10 |
+
component: triage_llm
|
| 11 |
+
model_id: openbmb/MiniCPM-5-1B
|
| 12 |
+
table_parser:
|
| 13 |
+
component: table_parser
|
| 14 |
+
model_id: nvidia/NVIDIA-Nemotron-Parse-v1.1
|
| 15 |
+
asr:
|
| 16 |
+
component: asr
|
| 17 |
+
model_id: CohereLabs/cohere-transcribe-03-2026
|
data/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Data
|
| 2 |
+
|
| 3 |
+
Bundled demo packs:
|
| 4 |
+
- `p1_elder_paperwork/` — synthetic elder-paperwork notices, OCR-lite samples, and test packs for the P1 app.
|
| 5 |
+
|
| 6 |
+
All bundled P1 demo assets are synthetic or generated for this repository and are intended to be redistributable in a public Hugging Face Space.
|
| 7 |
+
|
| 8 |
+
License for bundled demo assets: CC0-1.0.
|
| 9 |
+
No PII/PHI is included.
|
data/demo_packs/p1_elder_paperwork/sample_appointment_notice/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
p1_elder_paperwork pack: sample_appointment_notice
|
data/demo_packs/p1_elder_paperwork/sample_appointment_notice/inputs/note.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Appointment notice for a routine follow-up visit next Tuesday. Please bring your medication list and insurance card.
|
data/demo_packs/p1_elder_paperwork/sample_appointment_notice/manifest.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"project": "p1",
|
| 3 |
+
"pack_id": "p1_elder_paperwork.sample_appointment_notice",
|
| 4 |
+
"description": "p1_elder_paperwork demo pack sample_appointment_notice",
|
| 5 |
+
"inputs": [
|
| 6 |
+
{
|
| 7 |
+
"path": "inputs/note.txt",
|
| 8 |
+
"kind": "txt",
|
| 9 |
+
"label": "sample_appointment_notice"
|
| 10 |
+
}
|
| 11 |
+
],
|
| 12 |
+
"expected_signals": {
|
| 13 |
+
"triage": "important"
|
| 14 |
+
},
|
| 15 |
+
"license": "CC0-1.0",
|
| 16 |
+
"source": "synthetic",
|
| 17 |
+
"primary_text": "Appointment notice for a routine follow-up visit next Tuesday. Please bring your medication list and insurance card."
|
| 18 |
+
}
|
data/demo_packs/p1_elder_paperwork/sample_benefits_renewal_png/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
p1_elder_paperwork pack: sample_benefits_renewal_png
|
data/demo_packs/p1_elder_paperwork/sample_benefits_renewal_png/manifest.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"project": "p1",
|
| 3 |
+
"pack_id": "p1_elder_paperwork.sample_benefits_renewal_png",
|
| 4 |
+
"description": "p1_elder_paperwork demo pack sample_benefits_renewal_png",
|
| 5 |
+
"inputs": [
|
| 6 |
+
{
|
| 7 |
+
"path": "inputs/benefits.png",
|
| 8 |
+
"kind": "png",
|
| 9 |
+
"label": "sample_benefits_renewal_png"
|
| 10 |
+
}
|
| 11 |
+
],
|
| 12 |
+
"expected_signals": {
|
| 13 |
+
"triage": "important"
|
| 14 |
+
},
|
| 15 |
+
"license": "CC0-1.0",
|
| 16 |
+
"source": "synthetic",
|
| 17 |
+
"primary_text": "Benefits renewal reminder. Return the verification form within 10 days to keep coverage active."
|
| 18 |
+
}
|
data/demo_packs/p1_elder_paperwork/sample_clinic_reminder_txt/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
p1_elder_paperwork pack: sample_clinic_reminder_txt
|
data/demo_packs/p1_elder_paperwork/sample_clinic_reminder_txt/inputs/reminder.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Clinic reminder: annual wellness visit scheduled for next month. This is a routine informational notice.
|
data/demo_packs/p1_elder_paperwork/sample_clinic_reminder_txt/manifest.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"project": "p1",
|
| 3 |
+
"pack_id": "p1_elder_paperwork.sample_clinic_reminder_txt",
|
| 4 |
+
"description": "p1_elder_paperwork demo pack sample_clinic_reminder_txt",
|
| 5 |
+
"inputs": [
|
| 6 |
+
{
|
| 7 |
+
"path": "inputs/reminder.txt",
|
| 8 |
+
"kind": "txt",
|
| 9 |
+
"label": "sample_clinic_reminder_txt"
|
| 10 |
+
}
|
| 11 |
+
],
|
| 12 |
+
"expected_signals": {
|
| 13 |
+
"triage": "important"
|
| 14 |
+
},
|
| 15 |
+
"license": "CC0-1.0",
|
| 16 |
+
"source": "synthetic",
|
| 17 |
+
"primary_text": "Clinic reminder: annual wellness visit scheduled for next month. This is a routine informational notice."
|
| 18 |
+
}
|
data/demo_packs/p1_elder_paperwork/sample_final_notice_pdf/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
p1_elder_paperwork pack: sample_final_notice_pdf
|
data/demo_packs/p1_elder_paperwork/sample_final_notice_pdf/inputs/notice.pdf
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
1 0 obj
|
| 3 |
+
<< /Type /Catalog /Pages 2 0 R >>
|
| 4 |
+
endobj
|
| 5 |
+
2 0 obj
|
| 6 |
+
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
| 7 |
+
endobj
|
| 8 |
+
3 0 obj
|
| 9 |
+
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>
|
| 10 |
+
endobj
|
| 11 |
+
4 0 obj
|
| 12 |
+
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
| 13 |
+
endobj
|
| 14 |
+
5 0 obj
|
| 15 |
+
<< /Length 135 >>
|
| 16 |
+
stream
|
| 17 |
+
BT /F1 12 Tf 72 720 Td (Final notice: your account is past due. Please pay the remaining balance by 06/18/2026 or call 555-0142.) Tj ET
|
| 18 |
+
endstream
|
| 19 |
+
endobj
|
| 20 |
+
xref
|
| 21 |
+
0 6
|
| 22 |
+
0000000000 65535 f
|
| 23 |
+
0000000009 00000 n
|
| 24 |
+
0000000058 00000 n
|
| 25 |
+
0000000115 00000 n
|
| 26 |
+
0000000241 00000 n
|
| 27 |
+
0000000311 00000 n
|
| 28 |
+
trailer
|
| 29 |
+
<< /Size 6 /Root 1 0 R >>
|
| 30 |
+
startxref
|
| 31 |
+
497
|
| 32 |
+
%%EOF
|
data/demo_packs/p1_elder_paperwork/sample_final_notice_pdf/manifest.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"project": "p1",
|
| 3 |
+
"pack_id": "p1_elder_paperwork.sample_final_notice_pdf",
|
| 4 |
+
"description": "p1_elder_paperwork demo pack sample_final_notice_pdf",
|
| 5 |
+
"inputs": [
|
| 6 |
+
{
|
| 7 |
+
"path": "inputs/notice.pdf",
|
| 8 |
+
"kind": "pdf",
|
| 9 |
+
"label": "sample_final_notice_pdf"
|
| 10 |
+
}
|
| 11 |
+
],
|
| 12 |
+
"expected_signals": {
|
| 13 |
+
"triage": "urgent"
|
| 14 |
+
},
|
| 15 |
+
"license": "CC0-1.0",
|
| 16 |
+
"source": "synthetic",
|
| 17 |
+
"primary_text": "Final notice: your account is past due. Please pay the remaining balance by 06/18/2026 or call 555-0142."
|
| 18 |
+
}
|
data/demo_packs/p1_elder_paperwork/sample_insurance_letter_png/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
p1_elder_paperwork pack: sample_insurance_letter_png
|
data/demo_packs/p1_elder_paperwork/sample_insurance_letter_png/manifest.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"project": "p1",
|
| 3 |
+
"pack_id": "p1_elder_paperwork.sample_insurance_letter_png",
|
| 4 |
+
"description": "p1_elder_paperwork demo pack sample_insurance_letter_png",
|
| 5 |
+
"inputs": [
|
| 6 |
+
{
|
| 7 |
+
"path": "inputs/insurance_letter.png",
|
| 8 |
+
"kind": "png",
|
| 9 |
+
"label": "sample_insurance_letter_png"
|
| 10 |
+
}
|
| 11 |
+
],
|
| 12 |
+
"expected_signals": {
|
| 13 |
+
"triage": "important"
|
| 14 |
+
},
|
| 15 |
+
"license": "CC0-1.0",
|
| 16 |
+
"source": "synthetic",
|
| 17 |
+
"primary_text": "Insurance letter with coverage update and appeal rights. Contact member services if the mailing address is wrong."
|
| 18 |
+
}
|
data/demo_packs/p1_elder_paperwork/sample_lab_reminder_txt/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
p1_elder_paperwork pack: sample_lab_reminder_txt
|
data/demo_packs/p1_elder_paperwork/sample_lab_reminder_txt/inputs/lab_reminder.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Lab reminder: routine blood work is due sometime this month. No action is needed today.
|
data/demo_packs/p1_elder_paperwork/sample_lab_reminder_txt/manifest.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"project": "p1",
|
| 3 |
+
"pack_id": "p1_elder_paperwork.sample_lab_reminder_txt",
|
| 4 |
+
"description": "p1_elder_paperwork demo pack sample_lab_reminder_txt",
|
| 5 |
+
"inputs": [
|
| 6 |
+
{
|
| 7 |
+
"path": "inputs/lab_reminder.txt",
|
| 8 |
+
"kind": "txt",
|
| 9 |
+
"label": "sample_lab_reminder_txt"
|
| 10 |
+
}
|
| 11 |
+
],
|
| 12 |
+
"expected_signals": {
|
| 13 |
+
"triage": "important"
|
| 14 |
+
},
|
| 15 |
+
"license": "CC0-1.0",
|
| 16 |
+
"source": "synthetic",
|
| 17 |
+
"primary_text": "Lab reminder: routine blood work is due sometime this month. No action is needed today."
|
| 18 |
+
}
|
data/demo_packs/p1_elder_paperwork/sample_medication_change_pdf/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
p1_elder_paperwork pack: sample_medication_change_pdf
|
data/demo_packs/p1_elder_paperwork/sample_medication_change_pdf/inputs/medication_change.pdf
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
1 0 obj
|
| 3 |
+
<< /Type /Catalog /Pages 2 0 R >>
|
| 4 |
+
endobj
|
| 5 |
+
2 0 obj
|
| 6 |
+
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
| 7 |
+
endobj
|
| 8 |
+
3 0 obj
|
| 9 |
+
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>
|
| 10 |
+
endobj
|
| 11 |
+
4 0 obj
|
| 12 |
+
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
| 13 |
+
endobj
|
| 14 |
+
5 0 obj
|
| 15 |
+
<< /Length 152 >>
|
| 16 |
+
stream
|
| 17 |
+
BT /F1 12 Tf 72 720 Td (Medication change update from clinic. Continue current dose and review the attached instructions at the next appointment.) Tj ET
|
| 18 |
+
endstream
|
| 19 |
+
endobj
|
| 20 |
+
xref
|
| 21 |
+
0 6
|
| 22 |
+
0000000000 65535 f
|
| 23 |
+
0000000009 00000 n
|
| 24 |
+
0000000058 00000 n
|
| 25 |
+
0000000115 00000 n
|
| 26 |
+
0000000241 00000 n
|
| 27 |
+
0000000311 00000 n
|
| 28 |
+
trailer
|
| 29 |
+
<< /Size 6 /Root 1 0 R >>
|
| 30 |
+
startxref
|
| 31 |
+
514
|
| 32 |
+
%%EOF
|
data/demo_packs/p1_elder_paperwork/sample_medication_change_pdf/manifest.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"project": "p1",
|
| 3 |
+
"pack_id": "p1_elder_paperwork.sample_medication_change_pdf",
|
| 4 |
+
"description": "p1_elder_paperwork demo pack sample_medication_change_pdf",
|
| 5 |
+
"inputs": [
|
| 6 |
+
{
|
| 7 |
+
"path": "inputs/medication_change.pdf",
|
| 8 |
+
"kind": "pdf",
|
| 9 |
+
"label": "sample_medication_change_pdf"
|
| 10 |
+
}
|
| 11 |
+
],
|
| 12 |
+
"expected_signals": {
|
| 13 |
+
"triage": "important"
|
| 14 |
+
},
|
| 15 |
+
"license": "CC0-1.0",
|
| 16 |
+
"source": "synthetic",
|
| 17 |
+
"primary_text": "Medication change update from clinic. Continue current dose and review the attached instructions at the next appointment."
|
| 18 |
+
}
|
data/demo_packs/p1_elder_paperwork/sample_urgent_notice/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
p1_elder_paperwork pack: sample_urgent_notice
|
data/demo_packs/p1_elder_paperwork/sample_urgent_notice/inputs/note.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
URGENT billing notice about a past due balance and follow-up deadline. Call the office by Friday.
|
data/demo_packs/p1_elder_paperwork/sample_urgent_notice/manifest.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"project": "p1",
|
| 3 |
+
"pack_id": "p1_elder_paperwork.sample_urgent_notice",
|
| 4 |
+
"description": "p1_elder_paperwork demo pack sample_urgent_notice",
|
| 5 |
+
"inputs": [
|
| 6 |
+
{
|
| 7 |
+
"path": "inputs/note.txt",
|
| 8 |
+
"kind": "txt",
|
| 9 |
+
"label": "sample_urgent_notice"
|
| 10 |
+
}
|
| 11 |
+
],
|
| 12 |
+
"expected_signals": {
|
| 13 |
+
"triage": "urgent"
|
| 14 |
+
},
|
| 15 |
+
"license": "CC0-1.0",
|
| 16 |
+
"source": "synthetic",
|
| 17 |
+
"primary_text": "URGENT billing notice about a past due balance and follow-up deadline. Call the office by Friday."
|
| 18 |
+
}
|