Spaces:
Sleeping
Agent Notes โ Fin-EvalOps
Notes left by coding agents for future agents. Read this before making changes.
Architecture in 30 seconds
- Backend: FastAPI + SQLAlchemy + SQLite (WAL). Lives in
backend/app/. Routers:testsets,runs,skills,dashboard,agent,annotations,sse. Background eval work inservices/evaluator.py. - Frontend: Vite + React + TS + AntD + ECharts. Lives in
frontend/src/. Key page:pages/TestSets/index.tsx. - Eval pipeline:
TestCaseโ auto-route to aSkill(services/skill_router.py) โ LLM judge (services/llm_client.py) โ store onRunwithweight_assignment,dimension_scores,caps,root_causes,narrative_review. - Deployment:
- Frontend โ GitHub Pages (auto via
.github/workflows/deploy.ymlon push tomaintouchingfrontend/**). - Backend โ HF Space
appqqq/Fin-EvalOps-v2(Docker SDK). NOT auto-synced from GitHub unless the user manually enables GitHub integration in the Space settings.
- Frontend โ GitHub Pages (auto via
- Persistence: SQLite DB lives in
/dataon the Space, snapshot-pushed to a HF Dataset repo viapersistence.py.
Recent work (2026-06 โ 2026-07)
Feature: custom business categories
Business asked: don't force every eval batch into one of the 13 seeded self-eval categories. Let users define their own.
- Backend (
routers/testsets.py): newPOST /api/testsets/categories(with body validation: code charset =^[\wไธ-้ฟฟ\-]+$, length 1-32) andDELETE /api/testsets/categories/{code}(refuses seedis_custom=falsewith 409; refuses in-use categories). - Model (
models.py):TestCategory.is_custom: Boolean+ inline migration. Widenedcode/category_codetoString(64)โ SQLite type affinity means no DDL migration needed. - Slug auto-derived as
c-{slugified-code}to keep custom in its own namespace and avoid colliding with the canonical English slugs. - Frontend (
pages/TestSets/index.tsx):+ ๆฐๅปบๅ็ฑปbutton next to the category Select in all 3 modals (import / new / iwencai-pull); new top-toolbarๅ็ฑป็ฎก็modal with delete popconfirm; tree node title appends ๐ท๏ธ for custom rows.
Fix: tags lost across all JSON import paths
_normalize_raw (Chinese-keyed branch) didn't extract tags; import_file / scan_disk / import-from-iwencai never wrote tags=... to the TestCase ORM. Result: any upstream metadata packed into tags (e.g. by scripts/convert_part06.py) silently disappeared. Fixed in the same PR โ tags=norm.get("tags") now propagates everywhere.
Fix: scan-disk 500 when testsets_root is missing
ๆฐๆฎๆต่ฏ้/ is now uploaded at runtime via the web UI instead of bundled in the Docker image (see "Things you must know" below). Without data on disk, POST /api/testsets/scan-disk returned 500. Fixed: now returns ScanDiskResponse(scanned=0, inserted=0, updated=0, skipped=0) and the frontend's "็ฃ็ๅๆญฅ" button just shows 0 instead of erroring.
Fix: eval pipeline โ parallel-array root_causes + tool_use text-block recovery
Two recurring eval failures observed in user runs:
- Judge outputs
root_causesas a dict of parallel arrays (e.g.{l1: ["a","b"], l2: ["x","y"], raw_score: ["40","60"], โฆ}) when the schema wants a list of objects. Same pattern occasionally oncaps,skipped_dimensions. Validator throwsSchemaValidationError. - minimax / other Anthropic-compatible APIs put JSON in a markdown text block instead of a
tool_useblock. The fallback tried to validate strictly; on failure the entire eval died.
Fix in services/llm_client.py:
- New
_sanitize_judge_for_validation()runs before strict validation. Detects "dict of parallel arrays" shape and transposes to list of objects. Coercesraw_score/score_ceilingstring-numbers to float. Splits stringmatched_golden_casesinto list. call_with_schemanow wraps validation: if it fails, fills missingrequiredfields with empty defaults, re-validates; if still failing, returns partial data with a warning instead of raising. Downstream evaluator sanitizers can recover the rest._call_anthropictext-block fallback: pre-sanitizes, then on residual mismatch logs a warning and returns data anyway.
Duplicate sanitizer in services/evaluator.py _sanitize_judge_output โ defense in depth, handles cached / out-of-band payloads.
Frontend: explicit "ๅผๅงๅฏผๅ ฅ" button on the JSON import modal
The Antd Upload.beforeUpload was firing the import the moment a file was selected, and Modal.footer={null} hid the default OK/Cancel. Users had no way to confirm the action. Now: file is staged in state; ๅผๅงๅฏผๅ
ฅ OK button (disabled until both category + file are set) triggers importFile; success message includes the destination category code.
Things you must know (operational gotchas)
HF Space sync โ there is NO direct git push from this environment
git push to huggingface.co/spaces/... fails from this machine (network policy blocks port 443 to huggingface.co). The workaround is huggingface_hub.HfApi:
from huggingface_hub import HfApi
api = HfApi(token=open("/Users/appstore/.cache/huggingface/token").read().strip())
api.upload_file(
path_or_fileobj=open("local/file", "rb").read(),
path_in_repo="remote/path",
repo_id="appqqq/Fin-EvalOps-v2",
repo_type="space",
commit_message="...",
)
HfApi uses an HTTP API path that is reachable; raw git over SSH or HTTPS to huggingface.co is not. Endpoints like model_info("gpt2") work without auth; authenticated writes need the cached token at ~/.cache/huggingface/token.
upload_folder is for many files at once (uses LFS for large ones); upload_file is for a single file commit. delete_files(repo_id, repo_type, delete_patterns=[...]) removes files with glob patterns.
SSL is intermittent โ wrap writes in a retry loop. Each upload_file / delete_files creates its own commit, so failures mid-upload don't roll back earlier commits.
If a fresh user runs into the same git push failure, use the script at scripts/sync_to_hf_space.sh which clones locally + rsyncs + commits (assumes the user can reach huggingface.co from their machine).
Dockerfile: do NOT COPY ๆฐๆฎๆต่ฏ้/
The Docker image must not bundle test data. The pattern is:
COPY backend/ /app/backend/
COPY skills/ /app/skills/
# ๆฐๆฎๆต่ฏ้/ is NOT bundled โ tests are uploaded via the web UI.
If you add the COPY line back, the build fails with "/ๆฐๆฎๆต่ฏ้/่ช็ ่ฏๆตๆต่ฏ้": not found (because the directory is excluded from the Space repo via the deletion commit 7d79f77a1f13).
.hfignore keeps test data out of HF uploads
/Users/appstore/AI-Code/Fin-EvalOps/.hfignore excludes ๆฐๆฎๆต่ฏ้/, *.jsonl, and _smoke_*.json. Also scripts/sync_to_hf_space.sh has matching rsync excludes. Don't re-add these โ they were excluded for a reason (don't bundle customer test data into the Space image).
SQLite migration pattern is _run_inline_migrations()
Adding a column? Put it in the MIGRATIONS list in backend/app/db.py with a predicate + action SQL. Predicate should return 1+ rows when migration is needed. SQLite ALTER TABLE only supports ADD COLUMN and RENAME COLUMN โ for anything more complex (e.g. widening column type), you need table rebuild or just leave the schema declaration alone (SQLite doesn't enforce VARCHAR length anyway).
TestCase is referenced everywhere โ be careful with column changes
TestCase is the central model. Test cases are created from JSON import, manual API, scan-disk, iwencai import. If you change a column, update:
- The model in
models.py _normalize_raw(handles Chinese-keyed JSON)- All 4 creation paths:
import_file,scan_disk,import-from-iwencai,create_testcase - The serializer (
schemas.py)
Missing any path โ silent data loss (this is exactly what bit us with tags).
Testing
cd backend && python3 -m pytest tests/ -v
49 tests, all passing. Patterns:
test_field_rename.pyโ DB-level migration tests (create temp DB with old schema, run migration, verify shape).test_custom_category.pyโ full-stack viaTestClientwith patched DB session.test_sanitize_judge_output.pyโ pure-function tests for LLM output reshaping (the 12 new cases cover parallel-array transpose + permissive fallback).test_skill_router.py,test_scorer.py,test_cache_headers.py,test_sanitize_judge_output.py(legacy) โ unit tests.
For end-to-end smoke tests against a live Space, you'll need an LLM API key. The HF Space has provider keys configured; for local testing set MINIMAX_API_KEY (or others) in env and run uvicorn app.main:app --port 8000.
File map (for agents coming in cold)
Fin-EvalOps/
โโโ backend/app/
โ โโโ main.py # FastAPI app, lifespan (HF persistence + skill sync)
โ โโโ config.py # Settings, env-driven
โ โโโ db.py # SQLite engine, migrations
โ โโโ models.py # ORM models (TestCase, Run, Skill, TestCategory, ...)
โ โโโ schemas.py # Pydantic I/O schemas
โ โโโ persistence.py # HF Datasets snapshot push/pull
โ โโโ routers/
โ โ โโโ testsets.py # โ most custom-category work lives here
โ โ โโโ runs.py # Run creation + polling
โ โ โโโ skills.py # Skill catalog API
โ โ โโโ dashboard.py
โ โ โโโ agent.py # Data Agent (LLM chat over the DB)
โ โ โโโ annotations.py
โ โ โโโ sse.py # SSE channels for live progress
โ โโโ services/
โ โโโ llm_client.py # โ sanitizer fixes live here
โ โโโ evaluator.py # โ 5-step eval pipeline; _sanitize_judge_output here
โ โโโ scorer.py # weighted-sum + cap rule application
โ โโโ skill_router.py # auto-route question โ skill
โ โโโ skill_loader.py # parses ่ช็ ่ฏๆตSkill/ markdown into Skill rows
โ โโโ data_agent.py # Data Agent LLM prompt
โ โโโ fetch_iwencai.py # iwencai EvalOps backend adapter
โโโ frontend/src/
โ โโโ pages/TestSets/ # โ UX overhaul for custom categories lives here
โ โโโ api/testsets.ts # frontend API client (createCategory, deleteCategory)
โ โโโ components/, pages/{Agent,Annotations,Compare,Dashboard,Runs,Skills,Settings}/
โโโ skills/ # 13 self-eval + 14 competitor + 14 e2e skill markdown
โโโ scripts/
โ โโโ convert_part06.py # JSONL โ platform-importable JSON converter
โ โโโ sync_to_hf_space.sh # git-based HF Space sync (only works for users with network access)
โโโ ๆฐๆฎๆต่ฏ้/ # โ ๏ธ NOT shipped to Space (excluded in .hfignore)
โ โโโ ่ช็ ่ฏๆตๆต่ฏ้/ # 13 canonical test set files
โ โโโ ็ซๅๅฏนๆฏๆต่ฏ้/
โ โโโ part_06.jsonl # new test data (user's)
โ โโโ part_06-importable.json # generated by scripts/convert_part06.py
โโโ .hfignore # excludes data from HF uploads
โโโ Dockerfile
Common tasks
Add a new eval metric dimension
- Update the skill's markdown in
skills/่ช็ ่ฏๆตSkill/<NN>-<slug>/(add dim to dimensions table, caps table, root_causes table). - Update the LLM schema in
backend/app/services/evaluator.pyEVAL_OUTPUT_SCHEMAdimension_scores.additionalPropertiesif you want strict validation. - The schema is shared across all 13 skills โ don't break the others. If a dimension is skill-specific, define it per-skill via a relaxed schema.
Add a new business category via API
curl -X POST https://appqqq-fin-evalops-v2.hf.space/api/testsets/categories \
-H 'Content-Type: application/json' \
-d '{"code":"my-batch-2026q3","name_zh":"2026Q3 ๅๅฝๆนๆฌก","description":"..."}'
Returns TestCategoryOut with is_custom: true, slug: c-my-batch-2026q3.
Import a test set into a custom category
curl -X POST 'https://appqqq-fin-evalops-v2.hf.space/api/testsets/import-file?category_code=my-batch-2026q3' \
-F 'file=@my_set.json'
Response: {"inserted": N, "total_in_file": M}. Returns 400 if the category doesn't exist; the import endpoint accepts both Chinese-keyed ({้ฎ้ข, ็ญๆก, ้พ่ทฏๆฐๆฎ, ไธไธๆ, id, ๆฅๆบ, tags}) and English-keyed ({question, agent_answer, reasoning_trace, context_history, source_id, source, tags}) JSON.
Trigger a single eval
curl -X POST https://appqqq-fin-evalops-v2.hf.space/api/runs \
-H 'Content-Type: application/json' \
-d '{"testcase_id":"<hex>","judge_model":"minimax-3"}'
Returns 201 with the run. Poll GET /api/runs/{run_id} for status. Auto-routes to the best matching skill unless skill_id is provided.
Known rough edges (not bugs, just be aware)
cost_usdis alwaysnullโModelSpechas no pricing fields. If you add pricing, updateLLMResultand the evaluator to computetokens_in * input_price + tokens_out * output_price.matched_golden_casesis often[]even when the question clearly matches a golden case โ judge models tend to be conservative. The golden case list is provided in the prompt (truncated to 6000 chars).scan-diskalways returns 0 on the Space (no bundled data). This is intentional after the 2026-07 cleanup.- HF Space file count is ~1598 because the previous sync included
.pytest_cache/etc. These are harmless dead files but inflate the repo. Safe to ignore.