diff --git a/.gitea/workflows/auto_backup_schedule.yml b/.gitea/workflows/auto_backup_schedule.yml index 6bfc502..25f4fbd 100644 --- a/.gitea/workflows/auto_backup_schedule.yml +++ b/.gitea/workflows/auto_backup_schedule.yml @@ -8,7 +8,7 @@ on: jobs: daily-backup: - runs-on: act-runner + runs-on: ubuntu-latest name: Daily Backup steps: @@ -41,7 +41,7 @@ jobs: ls -lh backups/ | tail -5 weekly-full-backup: - runs-on: act-runner + runs-on: ubuntu-latest name: Weekly Full Backup # 매주 월요일 1:00 UTC @@ -85,7 +85,7 @@ jobs: df -h | grep -E "Filesystem|data" backup-health-check: - runs-on: act-runner + runs-on: ubuntu-latest name: Backup Health Check # 매일 12:00 UTC @@ -127,7 +127,7 @@ jobs: du -sh backups/ | awk '{print "Total size: " $1}' test-recovery: - runs-on: act-runner + runs-on: ubuntu-latest name: Monthly Recovery Test # 매월 1일 2:00 UTC diff --git a/.gitea/workflows/backup.yml b/.gitea/workflows/backup.yml index 6fee1af..8a51ed8 100644 --- a/.gitea/workflows/backup.yml +++ b/.gitea/workflows/backup.yml @@ -7,7 +7,7 @@ on: jobs: backup: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run backup diff --git a/.gitea/workflows/calibration_backlog.yml b/.gitea/workflows/calibration_backlog.yml index 8c06377..be4fd24 100644 --- a/.gitea/workflows/calibration_backlog.yml +++ b/.gitea/workflows/calibration_backlog.yml @@ -7,7 +7,7 @@ on: jobs: build-calibration-backlog: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b1f00e4..fd9df35 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -20,7 +20,7 @@ on: jobs: validate-core: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code @@ -153,8 +153,11 @@ jobs: - name: Validate Snapshot Admin Workflow run: python3 tools/validate_snapshot_admin_workflow_v1.py + - name: Validate DB First Pipeline + run: python3 tools/validate_db_first_pipeline_v1.py + validate-ui-and-storage: - runs-on: self-hosted + runs-on: ubuntu-latest needs: validate-core if: github.event_name != 'push' diff --git a/.gitea/workflows/kis_data_collection.yml b/.gitea/workflows/kis_data_collection.yml index 71b06c1..3912e7b 100644 --- a/.gitea/workflows/kis_data_collection.yml +++ b/.gitea/workflows/kis_data_collection.yml @@ -31,7 +31,7 @@ on: jobs: validate-kis-config-smoke: if: github.event_name == 'workflow_dispatch' - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code run: | @@ -90,7 +90,7 @@ jobs: collect-kis-data-live: if: github.event_name == 'schedule' - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code diff --git a/.gitea/workflows/qualitative_sell_strategy.yml b/.gitea/workflows/qualitative_sell_strategy.yml index 8625287..bc962ca 100644 --- a/.gitea/workflows/qualitative_sell_strategy.yml +++ b/.gitea/workflows/qualitative_sell_strategy.yml @@ -7,7 +7,7 @@ on: jobs: evaluate-qualitative-sell: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code diff --git a/.gitea/workflows/snapshot_admin.yml b/.gitea/workflows/snapshot_admin.yml index 45ce4d3..cb99e8a 100644 --- a/.gitea/workflows/snapshot_admin.yml +++ b/.gitea/workflows/snapshot_admin.yml @@ -17,7 +17,7 @@ jobs: # Push-only smoke gate: no deployment, no web UI smoke, no long-running side effects. validate-snapshot-admin-smoke: if: github.event_name == 'push' - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code run: | @@ -50,10 +50,15 @@ jobs: echo "[smoke] validate workflow only (no web UI, no deploy)" python3 tools/validate_snapshot_admin_workflow_v1.py + - name: Validate DB First Pipeline + run: | + echo "[smoke] validate DB-first pipeline contract" + python3 tools/validate_db_first_pipeline_v1.py + # Manual dispatch gate: full workflow + web UI validation only. validate-snapshot-admin-full: if: github.event_name == 'workflow_dispatch' - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code run: | @@ -86,6 +91,11 @@ jobs: echo "[full] validate workflow" python3 tools/validate_snapshot_admin_workflow_v1.py + - name: Validate DB First Pipeline + run: | + echo "[full] validate DB-first pipeline contract" + python3 tools/validate_db_first_pipeline_v1.py + - name: Validate Snapshot Admin Web UI run: | echo "[full] validate web ui" diff --git a/.gitea/workflows/wbs_9_3_null_policy_ci_gate.yml b/.gitea/workflows/wbs_9_3_null_policy_ci_gate.yml index 2efd675..d365baf 100644 --- a/.gitea/workflows/wbs_9_3_null_policy_ci_gate.yml +++ b/.gitea/workflows/wbs_9_3_null_policy_ci_gate.yml @@ -14,7 +14,7 @@ on: jobs: null-policy-validation: - runs-on: act-runner + runs-on: ubuntu-latest name: NULL Policy Validation steps: diff --git a/AGENTS.md b/AGENTS.md index 0ba0b1f..c9975f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,22 @@ - 위 4가지 중 하나라도 빠지면 작업은 미완료다. 요약이나 설명만으로 완료 처리하지 않는다. - 완료 보고에는 반드시 변경된 YAML, 코드, 데이터 파일 경로와 검증 명령을 함께 적는다. +## 0c. 작업 수행 절차 강제 +- 모든 작업은 아래 순서를 반드시 따른다. + 1. `로드맵/현황 확인` + 2. `WBS 작성` + 3. `목표 설정` + 4. `성공판단 데이터 정의` + 5. `구현` + 6. `사후 검증` + 7. `증빙 기록` +- 작업 시작 전에는 반드시 해당 작업의 WBS 항목과 성공판단 데이터를 문장 또는 표로 먼저 확정한다. +- 성공판단 데이터가 없으면 구현을 시작하지 않는다. +- “한 줄 추가”, “작아 보이는 수정”도 예외가 아니다. 모든 변경은 WBS와 성공판단 데이터에 매핑되어야 한다. +- 작업 도중 범위가 바뀌면 WBS를 먼저 갱신하고 난 뒤에만 구현을 계속한다. +- 작업 완료 판정은 구현 완료가 아니라 검증 통과와 증빙 기록까지 확인된 경우에만 가능하다. +- 사후 검증 없이 “대충 괜찮다” 식의 진행은 금지한다. + ## 1. 읽는 순서 1. `runtime/active_artifact_manifest.yaml` 2. `Temp/final_decision_packet_active.json` (manifest alias) @@ -101,11 +117,14 @@ ## 5. 개발 규칙 - 새 기능은 contract, schema, golden case, owner ledger를 먼저 만든다. +- 그 다음에 WBS와 성공판단 데이터(테스트/검증 입력과 기대값)를 먼저 만든다. - 구현은 Python canonical first, GAS adapter second다. - `tools/*.py`는 CLI wrapper에 가깝게 유지한다. - `gas_*.gs`는 thin adapter 방향으로 유지한다. - `src/quant_engine`는 canonical package로 유지한다. - `schemas/generated`와 `src/quant_engine/models/generated`는 schema/model parity를 유지한다. +- 코드 변경은 WBS 항목 번호와 성공판단 데이터 파일/명령을 함께 남겨야 한다. +- 검증 결과가 없으면 완료 보고를 하지 않는다. - 경로가 새로 생기면 `AGENTS.md`의 Directory Routing / Serving 섹션과 zip 화이트리스트를 함께 갱신한다. - **Python 인터프리터**: Windows 로컬 환경에서는 반드시 `python`을 사용한다 (`python3` 금지). - `python` → Python 3.13.5 (`Python313/`) — yaml/openpyxl/yfinance 등 프로젝트 패키지 설치됨 diff --git a/docs/GITEA_SECRETS_SETUP.md b/docs/GITEA_SECRETS_SETUP.md index ca93ef3..3577169 100644 --- a/docs/GITEA_SECRETS_SETUP.md +++ b/docs/GITEA_SECRETS_SETUP.md @@ -19,6 +19,14 @@ - `KIS_APP_KEY` - `KIS_APP_SECRET` +## Token Cache Policy + +- KIS access token은 `Temp/kis_tokens.db`에 저장한다. +- 토큰은 `TOKEN_REFRESH_SKEW_MINUTES=10` 기준으로만 재사용/갱신한다. +- 토큰 캐시는 수집 DB와 분리한다. +- 토큰 캐시 상태는 `python tools/inspect_kis_token_cache_v1.py --json`로 점검한다. +- 토큰 갱신 실패 시 appkey/appsecret 또는 API 가용성 문제로만 판단하고, 시크릿 값을 로그나 알림에 그대로 노출하지 않는다. + ## Workflow Mapping - `.gitea/workflows/kis_data_collection.yml` @@ -35,6 +43,7 @@ - mock 계정은 유효성 확인용이다. - real 계정은 실제 데이터 수집용이다. - 둘을 같은 단계에서 혼용하지 않는다. +- 토큰 발급은 1일 1회 원칙을 따르며, 만료 전에는 캐시를 재사용한다. ## Verification diff --git a/docs/ROADMAP_WBS.md b/docs/ROADMAP_WBS.md index 41079da..34abe4c 100644 --- a/docs/ROADMAP_WBS.md +++ b/docs/ROADMAP_WBS.md @@ -5,6 +5,20 @@ --- +## 0a. 현재 실행 우선순위 + +> 2026-06-24 기준, v8.9 채택안(P0~P3)은 검증 완료 상태이며 새 구현 백로그의 최우선 순위는 아래 순서로 고정한다. + +1. `WBS-7.1` 캘리브레이션 임계값 실증 전환 +2. `WBS-7.7` 신규 시스템 E2E 통합 테스트 및 snapshot_admin 스모크 테스트 +3. `WBS-7.8` ETF NAV/괴리율/추적오차/AUM 수집 경로 확정 +4. `WBS-7.5` 임시 하드코딩 폴백 비례화의 실증 보정 +5. `WBS-7.6` 슬리피지 실측 보정 + +`WBS-7.2`, `WBS-7.3`, `WBS-7.4`, `WBS-7.10`~`WBS-7.14`는 현재 문서상 완료 또는 정리 완료로 유지한다. + +--- + ## 0b. 완료 조건 모든 작업은 아래 4가지 증빙이 함께 있을 때만 완료로 본다. @@ -16,6 +30,22 @@ 하나라도 빠지면 완료로 보지 않는다. +## 0c. 작업 절차 강제 + +모든 변경은 아래 순서를 지켜야 한다. + +1. 로드맵/현황 확인 +2. WBS 작성 +3. 목표 설정 +4. 성공판단 데이터 정의 +5. 구현 +6. 사후 검증 +7. 증빙 기록 + +작업 시작 전에 WBS와 성공판단 데이터를 먼저 확정해야 하며, 작은 수정도 예외가 아니다. +작업 도중 범위가 바뀌면 먼저 WBS를 갱신한 뒤 구현을 계속한다. +검증 증빙이 없으면 완료로 볼 수 없다. + --- ## 0c. 비판적 리뷰 (2026-06-21) diff --git a/prompts/analysis_prompt.md b/prompts/analysis_prompt.md index 63662e2..dc8d626 100644 --- a/prompts/analysis_prompt.md +++ b/prompts/analysis_prompt.md @@ -58,6 +58,19 @@ Use this prompt when producing an investment analysis or HTS-ready playbook. | GOAL_RETIREMENT_V1 | goal_current_asset_krw, goal_achievement_pct | {N}% 달성 / 잔여 {M}만원 / ETA {YYYY-MM} | IN_PROGRESS / ACHIEVED | **상황별 선택 추가 공식 (해당 시 반드시 포함):** + +--- + +## WORKFLOW DISCIPLINE + +작업 또는 수정 제안 전에 반드시 아래 4가지를 먼저 확정한다. + +1. WBS 항목 +2. 목표 +3. 성공판단 데이터 +4. 검증 명령 + +이 4가지가 명시되지 않으면 구현, 수정, 렌더링을 시작하지 않는다. - 매수 검토 시: `MEAN_REVERSION_GATE_V1` (이격도 체크 선행), `POSITION_SIZE_V1`, `RISK_BUDGET_CASCADE_V1`, `EXPECTED_EDGE_V1` - 매도 후보 시: `RS_RATIO_V1` (rs_laggard 판정), `SELL_PRIORITY_V1` - 가격 산출 시: `STOP_PRICE_CORE_V1`, `TAKE_PROFIT_LADDER_V2`, `TICK_NORMALIZER_V1` diff --git a/prompts/capture_parse_prompt.md b/prompts/capture_parse_prompt.md index 9759012..6ec1fcb 100644 --- a/prompts/capture_parse_prompt.md +++ b/prompts/capture_parse_prompt.md @@ -15,6 +15,19 @@ HTS 캡처 이미지가 제공되면 이 프롬프트를 **분석보다 먼저** --- +## WORKFLOW DISCIPLINE + +캡처 파싱 전에 반드시 아래 4가지를 먼저 확정한다. + +1. WBS 항목 +2. 목표 +3. 성공판단 데이터 +4. 검증 명령 + +이 4가지가 없으면 파싱을 시작하지 않는다. + +--- + ## STEP 1 — 화면 종류 판별 | 화면 | 판별 기준 | 사용 가능 여부 | diff --git a/prompts/review_prompt.md b/prompts/review_prompt.md index 18e8dd2..2804d6d 100644 --- a/prompts/review_prompt.md +++ b/prompts/review_prompt.md @@ -43,3 +43,16 @@ Do not approve: - PASS order without `execution_quality_table` - WATCH ledger using HTS order columns such as `지정가`, `손절가`, `익절가`, `주문수량`, or `주문금액` - prose headers such as `이번 주 결론`, `현재 포트폴리오 핵심 진단`, `보유 종목별 운용 지침`, `종합 의견` replacing required tables + +--- + +## WORKFLOW DISCIPLINE + +리뷰 전에 반드시 아래 4가지를 요구한다. + +1. WBS 항목 +2. 목표 +3. 성공판단 데이터 +4. 검증 명령 + +이 4가지가 없으면 리뷰 대상은 완료가 아니라 미완료로 판단한다. diff --git a/spec/02_data_contract.yaml b/spec/02_data_contract.yaml index 62c4104..d002a68 100644 --- a/spec/02_data_contract.yaml +++ b/spec/02_data_contract.yaml @@ -160,10 +160,10 @@ quant_feed_contract: - "data_integrity_score=100이어도 pending_critical_category_count>0이면 PASS_100 문구를 쓰지 않는다." json_analysis_protocol: - purpose: "GatherTradingData.json에서 시장 raw 분석 데이터를 빠르게 파싱해 data_completeness_matrix와 판단 입력으로 사용." + purpose: "GatherTradingData.json은 DB 기반 수집 결과를 바탕으로 생성된 파생 보고서 증빙이다. 최종 보고서 렌더링과 data_completeness_matrix 참고용으로만 사용한다." python_parsing_baseline: shell_rule: "PowerShell에서는 Bash heredoc 금지. '@ ... @ | python -' 형식으로 실행." - json_load_rule: "json.loads(Path('GatherTradingData.json').read_text(encoding='utf-8'))를 기본값으로 사용." + json_load_rule: "json.loads(Path('GatherTradingData.json').read_text(encoding='utf-8'))를 기본값으로 사용하되, 원천 추적은 SQLite DB의 history와 snapshot tables를 우선 확인한다." required_top_level: ["metadata", "data"] required_schema_version: "2026-05-18-json-raw-data-v1" required_paths: ["data.data_feed", "data.sector_flow", "data.macro", "data.event_risk", "data.core_satellite"] @@ -171,9 +171,9 @@ quant_feed_contract: text_columns: ["Ticker", "ETF_Code", "Proxy_Ticker", "Base_Ticker", "Constituent_Code", "ETF_Ticker", "Symbol", "ticker"] normalization: "숫자로 읽힌 91160.0, 5930.0 등은 문자열화 후 6자리 zero-pad 적용." validation_commands: ["npm run validate-data-sample", "npm run validate-specs"] - xlsx_refresh_rule: "xlsx 원본을 갱신했으면 npm run convert-data-json 실행 후 JSON을 다시 검증한다." + xlsx_refresh_rule: "xlsx 원본을 갱신했으면 먼저 DB에 반영한 뒤, 엔진이 DB를 읽어 JSON 파생 보고서를 재생성하고 다시 검증한다." xlsx_analysis_protocol: - purpose: "xlsx는 HTS 잔고·거래내역 판독 또는 raw JSON 재생성 감사를 위한 보조 프로토콜이다. 시장 raw 일반 분석은 json_analysis_protocol을 우선한다." + purpose: "xlsx는 HTS 잔고·거래내역 판독 또는 DB 반영 이전의 보조 감사 소스다. 시장 raw 일반 분석과 최종 보고서 생성은 DB 추적 후의 파생 JSON을 우선한다." python_parsing_baseline: shell_rule: "PowerShell에서는 Bash heredoc 금지. '@ ... @ | python -' 형식으로 실행." openpyxl_read_rule: "값 점검은 openpyxl.load_workbook(path, data_only=True, read_only=True)를 기본값으로 사용." diff --git a/spec/30_completion_criteria_contract.yaml b/spec/30_completion_criteria_contract.yaml index 0943225..fc56497 100644 --- a/spec/30_completion_criteria_contract.yaml +++ b/spec/30_completion_criteria_contract.yaml @@ -15,6 +15,20 @@ meta: engine_audit_ref: Temp/engine_audit_v1.json pass_100_ref: Temp/pass_100_criteria_v1.json +workflow_disciplines: + required_preimplementation_order: + - "로드맵/현황 확인" + - "WBS 작성" + - "목표 설정" + - "성공판단 데이터 정의" + - "구현" + - "사후 검증" + - "증빙 기록" + completion_gate_rule: "작업 시작 전 WBS와 성공판단 데이터가 명시되지 않으면 진행 금지" + small_change_rule: "한 줄 추가, 두 줄 추가 같은 소규모 변경도 동일하게 적용" + scope_change_rule: "작업 도중 범위가 바뀌면 먼저 WBS를 갱신한 뒤 계속 진행" + evidence_rule: "검증 증빙 없이는 완료로 간주하지 않음" + # ── §7 프롬프트 완료 조건 ──────────────────────────────────────────────────── criteria: diff --git a/spec/calibration_registry.yaml b/spec/calibration_registry.yaml index 47ab2c9..0a9249f 100644 --- a/spec/calibration_registry.yaml +++ b/spec/calibration_registry.yaml @@ -598,6 +598,28 @@ thresholds: sunset_date: '2026-09-30' unit: rsi value: 70.0 +- gs_location: gas_data_feed.gs:8780 + id: DSD_V1_ANALYST_PEG_BLOCK_PCT + last_calibrated: null + live_sample_requirement: 30 + notes: analystScore 보조 임계 — pegScore>=8이면 가점. 실증 전 EXPERT_PRIOR로 유지. + owner_formula: DISTRIBUTION_SELL_DETECTOR_V1 + sample_n: 0 + source: EXPERT_PRIOR + sunset_date: '2026-09-30' + unit: score_condition + value: 8.0 +- gs_location: gas_data_feed.gs:8780 + id: DSD_V1_ANALYST_UPSIDE_BLOCK_PCT + last_calibrated: null + live_sample_requirement: 30 + notes: analystScore 보조 임계 — upsidePct>15이면 가점. 실증 전 EXPERT_PRIOR로 유지. + owner_formula: DISTRIBUTION_SELL_DETECTOR_V1 + sample_n: 0 + source: EXPERT_PRIOR + sunset_date: '2026-09-30' + unit: pct + value: 15.0 - gs_location: gas_data_feed.gs:2098 id: HEAT_GATE_EVENT_SHOCK_HARD_BLOCK last_calibrated: null diff --git a/src/quant_engine/kis_api_client_v1.py b/src/quant_engine/kis_api_client_v1.py index 898b419..c8cb68e 100644 --- a/src/quant_engine/kis_api_client_v1.py +++ b/src/quant_engine/kis_api_client_v1.py @@ -168,13 +168,16 @@ def _issue_or_reuse_token(creds: KisCredentials) -> str: # 2. 토큰이 만료되었거나 없을 시 KIS API로 새로 발급 요청 requests = _requests() - resp = requests.post( - f"{creds.domain}/oauth2/tokenP", - json={"grant_type": "client_credentials", "appkey": creds.app_key, "appsecret": creds.app_secret}, - timeout=15, - ) - resp.raise_for_status() - body = resp.json() + try: + resp = requests.post( + f"{creds.domain}/oauth2/tokenP", + json={"grant_type": "client_credentials", "appkey": creds.app_key, "appsecret": creds.app_secret}, + timeout=15, + ) + resp.raise_for_status() + body = resp.json() + except Exception as exc: + raise RuntimeError("KIS token refresh failed; check credentials and API availability.") from exc access_token = body["access_token"] expires_in_sec = int(body.get("expires_in", 86400)) expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_in_sec) @@ -209,9 +212,12 @@ def _send_request(creds: KisCredentials, path: str, tr_id: str, params: dict[str "custtype": "P", } requests = _requests() - resp = requests.get(f"{creds.domain}{path}", headers=headers, params=params, timeout=15) - resp.raise_for_status() - return resp.json() + try: + resp = requests.get(f"{creds.domain}{path}", headers=headers, params=params, timeout=15) + resp.raise_for_status() + return resp.json() + except Exception as exc: + raise RuntimeError(f"KIS read-only request failed for {path} / {tr_id}.") from exc # ── 조회(read-only) 함수 — 전부 GET, 전부 quotations/ranking 카테고리 (실측 확인) ────────── diff --git a/src/quant_engine/kis_data_collection.db b/src/quant_engine/kis_data_collection.db index e141d3d..76ba7a0 100644 Binary files a/src/quant_engine/kis_data_collection.db and b/src/quant_engine/kis_data_collection.db differ diff --git a/src/quant_engine/kis_data_collection_v1.py b/src/quant_engine/kis_data_collection_v1.py index 1a45d5b..8347c2e 100644 --- a/src/quant_engine/kis_data_collection_v1.py +++ b/src/quant_engine/kis_data_collection_v1.py @@ -1,9 +1,9 @@ """KIS-first data collector for the CI scheduler. -The collector uses the existing `GatherTradingData.json` snapshot as the seed -universe, then enriches Korean tickers with read-only KIS quotations and -orderbook data, while retaining Naver/Yahoo fallbacks when available. -The canonical persistence target is SQLite. +The collector treats SQLite as the canonical persistence layer. +`GatherTradingData.json` is emitted only after the DB-backed collection run +finishes, so the JSON acts as a derived reporting artifact rather than the +source of truth. """ from __future__ import annotations diff --git a/src/quant_engine/snapshot_admin_server_v1.py b/src/quant_engine/snapshot_admin_server_v1.py index 1bea589..d2b397d 100644 --- a/src/quant_engine/snapshot_admin_server_v1.py +++ b/src/quant_engine/snapshot_admin_server_v1.py @@ -1,9 +1,12 @@ from __future__ import annotations import argparse +import datetime as dt import json import sqlite3 import subprocess +import time +import sys from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -15,6 +18,8 @@ ROOT = Path(__file__).resolve().parents[2] SNAPSHOT_ADMIN_VERSION = "snapshot-admin-web-v6" KIS_COLLECTION_DB = ROOT / "src" / "quant_engine" / "kis_data_collection.db" KIS_COLLECTION_REPORT = ROOT / "Temp" / "kis_data_collection_v1.json" +WORKBOOK_JSON = ROOT / "GatherTradingData.json" +WORKBOOK_XLSX = ROOT / "GatherTradingData.xlsx" QUALITATIVE_SELL_DB = ROOT / "outputs" / "qualitative_sell_strategy" / "qualitative_sell_strategy.db" # WBS-7.9 부속 — 테이블별 그리드 조회(Tabler). 화이트리스트에 없는 테이블명은 @@ -285,9 +290,15 @@ def _source_fingerprint() -> dict[str, Any]: latest_mtime = max(latest_mtime, path.stat().st_mtime) except OSError: continue + latest_updated_at = "" + if latest_mtime: + latest_updated_at = dt.datetime.fromtimestamp(latest_mtime, tz=dt.timezone.utc).astimezone( + dt.timezone(dt.timedelta(hours=9)) + ).isoformat() return { "fingerprint": digest.hexdigest()[:16], "latest_mtime": latest_mtime, + "latest_updated_at": latest_updated_at, } @@ -331,6 +342,7 @@ def build_ui_state(db_path: Path | str | None = None) -> dict[str, Any]: collection = load_collection_dashboard_state(KIS_COLLECTION_DB, KIS_COLLECTION_REPORT) except Exception: collection = {} + workbook_registry = load_workbook_sheet_registry() return { "version": { "app": SNAPSHOT_ADMIN_VERSION, @@ -358,10 +370,246 @@ def build_ui_state(db_path: Path | str | None = None) -> dict[str, Any]: }, "autofix_actions": autofix_actions, "collection": collection, + "workbook_registry": workbook_registry, "generated_at": now_kst_iso(), } +def load_workbook_sheet_registry() -> dict[str, Any]: + try: + payload = json.loads(WORKBOOK_JSON.read_text(encoding="utf-8")) + except Exception: + payload = {} + metadata = payload.get("metadata") if isinstance(payload, dict) else {} + sheets = metadata.get("sheets_included") if isinstance(metadata, dict) else [] + sheet_headers = metadata.get("sheet_headers") if isinstance(metadata, dict) else {} + data = payload.get("data") if isinstance(payload, dict) else {} + if not isinstance(sheets, list): + sheets = [] + if not isinstance(data, dict): + data = {} + entries: list[dict[str, Any]] = [] + for sheet in sheets: + sheet_name = str(sheet) + source_role = "derived_report_evidence" + if sheet_name in {"settings", "account_snapshot"}: + destination = "snapshot_admin.db" + kind = "workspace_db" + purpose = "workspace_edit" + source_role = "canonical_db" + elif sheet_name == "data_feed": + destination = "kis_data_collection.db" + kind = "collector_db" + purpose = "collector_run" + source_role = "collector_db" + elif sheet_name in {"sector_universe_refresh_audit", "daily_history", "event_calendar", "pa1_feedback", "alpha_history", "backdata_feature_bank", "sell_priority", "harness_context", "monthly_history", "sector_flow_history", "sector_universe", "core_satellite", "universe", "event_risk", "macro", "sector_flow"}: + destination = "GatherTradingData.json" + if sheet_name in {"sector_universe_refresh_audit"}: + purpose = "refresh_audit" + elif sheet_name in {"daily_history"}: + purpose = "history_ledger" + elif sheet_name in {"event_calendar", "pa1_feedback"}: + purpose = "audit_history" + elif sheet_name in {"alpha_history", "backdata_feature_bank", "sell_priority"}: + purpose = "analysis_report" + elif sheet_name in {"harness_context"}: + purpose = "execution_context" + elif sheet_name in {"monthly_history", "sector_flow_history"}: + purpose = "history_ledger" + elif sheet_name in {"sector_universe", "core_satellite", "universe"}: + purpose = "universe_registry" + elif sheet_name in {"event_risk", "macro"}: + purpose = "macro_risk_context" + elif sheet_name in {"sector_flow"}: + purpose = "flow_leadership" + else: + purpose = "json_payload" + kind = "json_payload" + else: + destination = "unknown" + kind = "unmapped" + purpose = "unmapped" + header_meta = sheet_headers.get(sheet_name) if isinstance(sheet_headers, dict) else {} + entries.append( + { + "sheet": sheet_name, + "destination": destination, + "kind": kind, + "purpose": purpose, + "source_role": source_role, + "has_json_payload": sheet_name in data, + "row_count": _sheet_payload_row_count(data.get(sheet_name), header_meta), + } + ) + explicit_target_sheets = [ + "sector_universe_refresh_audit", + "daily_history", + "event_calendar", + "pa1_feedback", + "alpha_history", + "backdata_feature_bank", + "sell_priority", + "harness_context", + "monthly_history", + "sector_flow_history", + "sector_universe", + "core_satellite", + "universe", + "event_risk", + "macro", + "sector_flow", + "data_feed", + ] + existing_sheets = {item["sheet"] for item in entries} + for sheet_name in explicit_target_sheets: + if sheet_name in existing_sheets: + continue + if sheet_name == "data_feed": + destination = "kis_data_collection.db" + kind = "collector_db" + purpose = "collector_run" + source_role = "collector_db" + else: + destination = "GatherTradingData.json" + kind = "json_payload" + source_role = "derived_report_evidence" + if sheet_name == "sector_universe_refresh_audit": + purpose = "refresh_audit" + elif sheet_name == "daily_history": + purpose = "history_ledger" + elif sheet_name in {"event_calendar", "pa1_feedback"}: + purpose = "audit_history" + elif sheet_name in {"alpha_history", "backdata_feature_bank", "sell_priority"}: + purpose = "analysis_report" + elif sheet_name == "harness_context": + purpose = "execution_context" + elif sheet_name in {"monthly_history", "sector_flow_history"}: + purpose = "history_ledger" + elif sheet_name in {"sector_universe", "core_satellite", "universe"}: + purpose = "universe_registry" + elif sheet_name in {"event_risk", "macro"}: + purpose = "macro_risk_context" + elif sheet_name == "sector_flow": + purpose = "flow_leadership" + else: + purpose = "json_payload" + entries.append( + { + "sheet": sheet_name, + "destination": destination, + "kind": kind, + "purpose": purpose, + "source_role": source_role, + "has_json_payload": kind == "json_payload", + "row_count": 0, + } + ) + return { + "xlsx_path": str(WORKBOOK_XLSX), + "json_path": str(WORKBOOK_JSON), + "json_role": "derived_report_evidence", + "sheet_count": len(entries), + "entries": entries, + "unmapped_sheets": [item["sheet"] for item in entries if item["kind"] == "unmapped"], + } + + +def _sheet_payload_row_count(value: Any, header_meta: Any | None = None) -> int | None: + if isinstance(header_meta, dict) and isinstance(header_meta.get("row_count"), int): + return int(header_meta["row_count"]) + if isinstance(value, list): + return len(value) + if isinstance(value, dict): + return len(value) + if value is None: + return 0 + return None + + +def run_collection_job( + *, + sqlite_db: Path | None = None, + input_json: Path | None = None, + output_json: Path | None = None, + allow_naver_fallback: bool = False, + include_live_kis: bool = False, + kis_account: str = "real", +) -> dict[str, Any]: + sqlite_db = sqlite_db or KIS_COLLECTION_DB + input_json = input_json or (ROOT / "GatherTradingData.json") + output_json = output_json or KIS_COLLECTION_REPORT + cmd = [ + sys.executable, + str(ROOT / "tools" / "run_kis_data_collection_v1.py"), + "--input-json", + str(input_json), + "--sqlite-db", + str(sqlite_db), + "--output-json", + str(output_json), + "--kis-account", + kis_account, + ] + if allow_naver_fallback: + cmd.append("--allow-naver-fallback") + if not include_live_kis: + cmd.append("--no-live-kis") + + started_at = now_kst_iso() + started = time.perf_counter() + proc = subprocess.run( + cmd, + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + ) + finished_at = now_kst_iso() + elapsed_ms = round((time.perf_counter() - started) * 1000.0, 1) + summary = {} + if output_json.exists(): + try: + loaded = json.loads(output_json.read_text(encoding="utf-8")) + summary = loaded if isinstance(loaded, dict) else {} + except Exception: + summary = {} + state = {} + try: + state = load_collection_dashboard_state(sqlite_db, output_json) + except Exception: + state = {} + return { + "status": "PASS" if proc.returncode == 0 else "FAIL", + "started_at": started_at, + "finished_at": finished_at, + "elapsed_ms": elapsed_ms, + "command": cmd, + "returncode": proc.returncode, + "stdout": proc.stdout, + "stderr": proc.stderr, + "summary": summary, + "state": state, + } + + +def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: Any) -> None: + body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + +def _text_response(handler: BaseHTTPRequestHandler, status: int, text: str, content_type: str = "text/plain; charset=utf-8") -> None: + body = text.encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", content_type) + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: Any) -> None: body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") handler.send_response(status) @@ -389,6 +637,118 @@ def _read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]: return payload +def render_home_html() -> str: + return """ + + + + + Snapshot Admin Home + + + +
+
+

Snapshot Admin Home

+
이 화면은 운영 판단용입니다. 편집, 승인, 수집이 모두 한 곳에 섞여 있으면 목적이 흐려지므로, 먼저 해야 할 일만 보여줍니다.
+
+
+
1. Workspace
+ 데이터를 고치고 저장 +

settings와 account_snapshot 편집, TSV 적용, 검증 확인은 workspace에서 처리합니다.

+
+
+
2. Collection
+ 수집 실행과 결과 확인 +

수집 버튼, 진행상태, 결과 로그는 collection 화면에 둡니다.

+
+
+
3. Tables
+ DB와 JSON 증빙을 분리 검토 +

DB별 조회, JSON별 조회, 시계열 이력은 tables에서 확인합니다.

+
+
+ +
테이블 브라우저는 보조 경로로 /tables에 둡니다.
+
+
+ + +""" + + +def render_inspector_html() -> str: + return """ + + + + + Snapshot Inspector + + + +
+
+

Snapshot Inspector

+
Row-level operations move here so the workspace stays focused on editing.
+
+ Open workspace + Home + + + +
+
Select a domain and target_ref, then load.
+

+    
+
+ + + +""" + + def render_index_html() -> str: return """ @@ -397,53 +757,84 @@ def render_index_html() -> str: Snapshot Admin @@ -949,6 +1400,7 @@ def render_index_html() -> str:
Open collection dashboard Open table browser + Open home
@@ -972,6 +1424,29 @@ def render_index_html() -> str:
Snapshot approval state and lock state are pinned here for immediate review.
+
+
+
+ Now + Loading current workspace status... + This page summarizes the canonical SQLite workspace and collection state. +
+
+ Next action + Loading... + The page will tell you whether to edit, approve, unlock, or collect. + +
+
+ Collection + Loading... + Latest collector run and result summary. +
+
+
@@ -1004,10 +1479,15 @@ def render_index_html() -> str:
-
-
+
+

Approval & Locks

+ open when you need to approve or lock rows +
+
+
+
@@ -1015,8 +1495,6 @@ def render_index_html() -> str:
-
-
settings approval
@@ -1046,22 +1524,29 @@ def render_index_html() -> str:
-
+ + -
-
+
+

KIS Collection

+ open when you want to run or inspect collector output +
+
+
+
-
-
collection: loading...
+
Collection trend
+
+
@@ -1079,20 +1564,23 @@ def render_index_html() -> str:
-
+ -
-
+
+

Selection Inspector

+ open when you need row-level operations +
+
+
+
-
-
No row selected.
@@ -1115,7 +1603,7 @@ def render_index_html() -> str:
-
+
@@ -2230,9 +2718,71 @@ def render_index_html() -> str: ].join("\n"); } + function summarizeNextAction() { + const settingsErrors = (state.validation?.settings || []).length; + const snapshotErrors = (state.validation?.account_snapshot || []).length; + const settingsApproval = state.approvalSettings?.status || "MISSING"; + const snapshotApproval = state.approvalSnapshot?.status || "MISSING"; + const lockCount = (state.locks || []).length; + if (settingsErrors || snapshotErrors) { + return { + title: "Fix validation issues", + detail: `${settingsErrors + snapshotErrors} validation issue(s) remain. Edit the workspace before approving or exporting.`, + }; + } + if (settingsApproval !== "APPROVED" || snapshotApproval !== "APPROVED") { + return { + title: "Approve the current workspace", + detail: `Approval status is settings=${settingsApproval}, snapshot=${snapshotApproval}. Review and approve the rows that are ready.`, + }; + } + if (lockCount > 0) { + return { + title: "Resolve active locks", + detail: `${lockCount} lock(s) are active. Unlock only after confirming the affected rows are stable.`, + }; + } + return { + title: "Workspace is ready", + detail: "Approvals are clear and no active locks remain. Export or continue with downstream work.", + }; + } + + function summarizeCollectionAction(collection, latestRun, latestReport) { + const runStatus = String(latestRun?.status || "").trim(); + const runId = String(latestRun?.run_id || "").trim(); + if (!runId) { + return { + title: "No collection run yet", + detail: "Use Collect now to create the first collector run and populate the run history.", + }; + } + if (runStatus === "FAIL") { + return { + title: "Collector failed", + detail: `Latest run ${runId} failed. Check the run log and error rows before retrying.`, + }; + } + return { + title: `Latest collection ${runStatus || "PASS"}`, + detail: `Run ${runId} finished at ${latestRun?.finished_at || "N/A"}. Report status is ${latestReport?.status || "N/A"}.`, + }; + } + + function setPanelOpen(panelId, shouldOpen) { + const panel = document.getElementById(panelId); + if (panel) panel.open = Boolean(shouldOpen); + } + function renderMeta() { const settingsApproval = state.approvalSettings || {}; const snapshotApproval = state.approvalSnapshot || {}; + const settingsErrors = state.validation?.settings || []; + const snapshotErrors = state.validation?.account_snapshot || []; + const lockCount = (state.locks || []).length; + const collection = state.collection || {}; + const latestRun = collection.latest_run || {}; + const latestReport = collection.latest_report || {}; const version = state.version || {}; const git = version.git || {}; const source = version.source || {}; @@ -2272,6 +2822,40 @@ def render_index_html() -> str: `change_log=${historyCounts.changes ?? 0}, approvals=${historyCounts.approvals ?? 0}, locks=${historyCounts.locks ?? 0}` + (changeLogFilter ? `, filtered=${visibleChanges.length}` : "") + (recent ? ` | latest=${recent.domain}:${recent.action}:${recent.target_ref} @ ${recent.created_at}` : ""); + const nextAction = summarizeNextAction(); + const collectionAction = summarizeCollectionAction(collection, latestRun, latestReport); + const heroNow = document.getElementById("heroNow"); + const heroNowDetail = document.getElementById("heroNowDetail"); + const heroNextAction = document.getElementById("heroNextAction"); + const heroNextDetail = document.getElementById("heroNextDetail"); + const heroPrimaryAction = document.getElementById("heroPrimaryAction"); + const heroCollection = document.getElementById("heroCollection"); + const heroCollectionDetail = document.getElementById("heroCollectionDetail"); + if (heroNow) { + heroNow.textContent = `Workspace rows: settings=${state.summary?.settings_rows ?? 0}, snapshot=${state.summary?.account_snapshot_rows ?? 0}`; + } + if (heroNowDetail) { + heroNowDetail.textContent = `Approval: settings=${settingsApproval.status || "MISSING"}, snapshot=${snapshotApproval.status || "MISSING"} | locks=${locks.length}`; + } + if (heroNextAction) heroNextAction.textContent = nextAction.title; + if (heroNextDetail) heroNextDetail.textContent = nextAction.detail; + if (heroPrimaryAction) { + heroPrimaryAction.textContent = nextAction.title; + const workspaceNeedsAttention = settingsErrors.length > 0 || snapshotErrors.length > 0 || settingsApproval.status !== "APPROVED" || snapshotApproval.status !== "APPROVED" || lockCount > 0; + heroPrimaryAction.href = workspaceNeedsAttention ? "/" : "/collection"; + } + if (heroCollection) { + heroCollection.textContent = collectionAction.title; + } + if (heroCollectionDetail) { + heroCollectionDetail.textContent = collectionAction.detail; + } + const shouldOpenWorkspace = settingsErrors.length > 0 || snapshotErrors.length > 0; + const shouldOpenApproval = !shouldOpenWorkspace && (settingsApproval.status !== "APPROVED" || snapshotApproval.status !== "APPROVED" || lockCount > 0); + const shouldOpenCollection = !shouldOpenWorkspace && !shouldOpenApproval && (!latestRun.run_id || latestRun.status === "FAIL"); + setPanelOpen("approvalPanel", shouldOpenApproval); + setPanelOpen("collectionPanel", shouldOpenCollection); + setPanelOpen("selectionPanel", Boolean(state.selected?.domain) && !shouldOpenWorkspace && !shouldOpenApproval && !shouldOpenCollection); document.getElementById("bannerApprovalSummary").textContent = `settings=${settingsApproval.status || "MISSING"} | snapshot=${snapshotApproval.status || "MISSING"}`; document.getElementById("bannerLockSummary").textContent = @@ -2290,17 +2874,27 @@ def render_index_html() -> str: }).join("\n"); const timelineEl = document.getElementById("changeTimeline"); if (timelineEl) { + const changeSummary = visibleChanges.slice(0, 12).map((item) => ({ + ts: String(item.created_at || "").slice(5, 16), + label: `${item.domain}:${item.action}`, + })); timelineEl.innerHTML = visibleChanges.length - ? visibleChanges.map((item) => { - const before = Array.isArray(item.before_json) ? item.before_json : []; - const after = Array.isArray(item.after_json) ? item.after_json : []; - return ` -
- ${esc(item.domain)} · ${esc(item.action)} · ${esc(item.target_ref)} · ${esc(item.created_at)} - ${esc(JSON.stringify({ before, after }, null, 2))} -
- `; - }).join("") + ? ` +
Recent change summary
+
+ ${changeSummary.map((item) => `${esc(item.ts || "N/A")} · ${esc(item.label)}`).join("")} +
+ ${visibleChanges.map((item) => { + const before = Array.isArray(item.before_json) ? item.before_json : []; + const after = Array.isArray(item.after_json) ? item.after_json : []; + return ` +
+ ${esc(item.domain)} · ${esc(item.action)} · ${esc(item.target_ref)} · ${esc(item.created_at)} + ${esc(JSON.stringify({ before, after }, null, 2))} +
+ `; + }).join("")} + ` : "
No timeline entries.
"; } } @@ -2335,8 +2929,9 @@ def render_index_html() -> str: const runsEl = document.getElementById("collectionRuns"); const snapshotsEl = document.getElementById("collectionSnapshots"); const errorsEl = document.getElementById("collectionErrors"); + const timelineEl = document.getElementById("collectionTimeline"); const detailEl = document.getElementById("collectionDetail"); - if (!chip || !summary || !metrics || !runsEl || !snapshotsEl || !errorsEl || !detailEl) return; + if (!chip || !summary || !metrics || !runsEl || !snapshotsEl || !errorsEl || !timelineEl || !detailEl) return; const filterText = String(document.getElementById("collectionFilter")?.value || state.filterCollection || "").trim().toLowerCase(); state.filterCollection = filterText; chip.textContent = `collector: ${counts.collection_runs ?? 0} runs / ${counts.collection_snapshots ?? 0} snapshots / ${counts.collection_source_errors ?? 0} errors`; @@ -2346,6 +2941,7 @@ def render_index_html() -> str: latestRun.run_id ? `latest_run=${latestRun.run_id}` : "", latestReport.status ? `latest_status=${latestReport.status}` : "", latestReport.generated_at ? `generated_at=${latestReport.generated_at}` : "", + state.version?.source?.latest_updated_at ? `final_updated_at=${state.version.source.latest_updated_at}` : "", filterText ? `filter=${filterText}` : "", ].filter(Boolean).join(" | "); const sourceCounts = latestReport.source_counts || {}; @@ -2378,7 +2974,7 @@ def render_index_html() -> str: const key = currentCollectionSelectionKey(kind, item, index); const selected = selection.kind === kind && selection.key === key; return ` - @@ -2388,6 +2984,23 @@ def render_index_html() -> str: runsEl.innerHTML = renderItemList("run", recentRuns, "No collection runs yet."); snapshotsEl.innerHTML = renderItemList("snapshot", recentSnapshots, "No collection snapshots yet."); errorsEl.innerHTML = renderItemList("error", recentErrors, "No collection errors."); + const activityRows = [ + ...recentRuns.map((item, index) => ({ kind: "run", ts: String(item.started_at || item.created_at || ""), label: `${item.status || "RUN"} ${item.collector_name || "collector"} ${item.run_id || ""}`, key: currentCollectionSelectionKey("run", item, index) })), + ...recentSnapshots.map((item, index) => ({ kind: "snapshot", ts: String(item.created_at || item.as_of_date || ""), label: `${item.dataset_name || "snapshot"}:${item.ticker || ""} ${item.source_status || ""}`, key: currentCollectionSelectionKey("snapshot", item, index) })), + ...recentErrors.map((item, index) => ({ kind: "error", ts: String(item.created_at || ""), label: `${item.source_name || "error"}:${item.error_kind || ""} ${item.ticker || ""}`, key: currentCollectionSelectionKey("error", item, index) })), + ].sort((a, b) => String(a.ts).localeCompare(String(b.ts))).slice(-18); + timelineEl.innerHTML = activityRows.length + ? activityRows.map((item, index) => { + const previous = activityRows[index - 1]; + const currentDate = String(item.ts || "").slice(0, 10); + const previousDate = previous ? String(previous.ts || "").slice(0, 10) : ""; + const dateHeader = !index || currentDate !== previousDate + ? `
${esc(currentDate || "Unknown date")}
` + : ""; + const selected = selection.kind === item.kind && selection.key === item.key; + return `${dateHeader}`; + }).join("") + : `
No unified activity yet.
`; const selectedItem = (() => { const key = String(selection.key || "").trim(); if (!key) return null; @@ -2573,7 +3186,7 @@ def render_index_html() -> str: } function _tsvRows(tsvText) { - return String(tsvText || "").replace(/\r/g, "").split("\n").filter((line) => line.trim() !== ""); + return String(tsvText || "").replace(/\\r/g, "").split("\\n").filter((line) => line.trim() !== ""); } function applyBatchPaste() { @@ -2708,25 +3321,62 @@ def render_collection_html() -> str: header { padding: 24px; border-bottom: 1px solid rgba(255,255,255,.06); position: sticky; top: 0; background: rgba(5,10,20,.6); backdrop-filter: blur(10px); z-index: 10; } h1 { margin: 0; font-size: 22px; } .subline { margin-top: 6px; color: var(--muted); font-size: 13px; } - .wrap { max-width: 1280px; margin: 0 auto; padding: 20px 24px 40px; } - .panel { border: 1px solid rgba(255,255,255,.08); border-radius: 18px; overflow: hidden; background: linear-gradient(180deg, rgba(17,24,39,.94), rgba(10,16,28,.95)); box-shadow: 0 16px 48px rgba(0,0,0,.24); } - .panel-head { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:12px; padding: 16px 16px 10px; border-bottom: 1px solid rgba(255,255,255,.06); } - .actions { display:flex; flex-wrap:wrap; gap:8px; } - button, a.btn { border: 1px solid rgba(255,255,255,.12); background: var(--chip); color: var(--text); padding: 8px 12px; border-radius: 10px; cursor:pointer; font-size:13px; text-decoration:none; } + .wrap { max-width: 1280px; margin: 0 auto; padding: 8px 12px 20px; } + .panel { border: 1px solid rgba(255,255,255,.08); border-radius: 12px; overflow: hidden; background: linear-gradient(180deg, rgba(17,24,39,.94), rgba(10,16,28,.95)); box-shadow: 0 8px 24px rgba(0,0,0,.24); } + .panel-head { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px; padding: 10px 12px 8px; border-bottom: 1px solid rgba(255,255,255,.06); } + .actions { display:flex; flex-wrap:wrap; gap:6px; } + button, a.btn { border: 1px solid rgba(255,255,255,.12); background: var(--chip); color: var(--text); padding: 6px 10px; border-radius: 8px; cursor:pointer; font-size:13px; text-decoration:none; } button.primary { background: linear-gradient(135deg, var(--accent), #0ea5e9); color:#fff; } - .pane { padding: 14px 16px 18px; } - .grid { display:grid; gap:12px; grid-template-columns: 1.1fr .9fr; } + button.primary.run-live { background: linear-gradient(135deg, #22c55e, #16a34a); box-shadow: 0 0 0 1px rgba(34,197,94,.18) inset; } + .pane { padding: 8px 12px 12px; } + .grid { display:grid; gap:8px; grid-template-columns: 1.1fr .9fr; } .chip { display:inline-flex; gap:6px; align-items:center; padding:4px 8px; border-radius:999px; background: rgba(255,255,255,.06); color: var(--muted); font-size:12px; } .muted { color: var(--muted); } - .metrics { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:8px; margin-top:10px; } - .metric { border: 1px solid rgba(255,255,255,.08); border-radius: 12px; padding: 10px; background: rgba(3,7,18,.42); font-size:12px; } + .metrics { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:6px; margin-top:6px; } + .metric { border: 1px solid rgba(255,255,255,.08); border-radius: 8px; padding: 8px; background: rgba(3,7,18,.42); font-size:12px; } .metric strong { display:block; font-size:16px; margin-top:4px; } - .filter-row { display:flex; flex-wrap:wrap; gap:8px; margin-top: 10px; } - .filter-row input { background: rgba(3,7,18,.45); color: var(--text); border: 1px solid rgba(255,255,255,.10); border-radius: 10px; padding: 8px 10px; font-size: 12px; min-width: 220px; } - .list { display:grid; gap:8px; margin-top: 8px; } - .item { width:100%; text-align:left; border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:10px; background: rgba(3,7,18,.42); color: var(--text); font-size:12px; cursor:pointer; } - .item.selected { border-color: rgba(96,165,250,.8); box-shadow: 0 0 0 1px rgba(96,165,250,.35) inset; } - .item code, pre { white-space: pre-wrap; } + .filter-row { display:flex; flex-wrap:wrap; gap:6px; margin-top: 6px; } + .filter-row input { background: rgba(3,7,18,.45); color: var(--text); border: 1px solid rgba(255,255,255,.10); border-radius: 8px; padding: 6px 8px; font-size: 12px; min-width: 220px; } + .run-form { display:flex; flex-wrap:wrap; gap:6px; margin-top: 6px; } + .run-form input, .run-form select { background: rgba(3,7,18,.45); color: var(--text); border: 1px solid rgba(255,255,255,.10); border-radius: 8px; padding: 6px 8px; font-size: 12px; min-width: 180px; } + .run-note { margin-top: 6px; color: var(--muted); font-size: 12px; } + .run-banner { + display: none; + margin-top: 8px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,.08); + background: rgba(3,7,18,.45); + color: var(--text); + font-size: 12px; + } + .run-banner.success { display: block; border-color: rgba(34,197,94,.36); background: rgba(34,197,94,.12); } + .run-banner.error { display: block; border-color: rgba(248,113,113,.36); background: rgba(248,113,113,.12); } + .list { display:grid; gap:4px; margin-top: 6px; } + .collection-item { + width: 100%; + text-align: left; + border: 1px solid rgba(255,255,255,.08); + border-radius: 12px; + padding: 10px; + background: rgba(3, 7, 18, .42); + color: var(--text); + font-size: 12px; + cursor: pointer; + } + .collection-item.selected { + border-color: rgba(96, 165, 250, .8); + box-shadow: 0 0 0 1px rgba(96, 165, 250, .35) inset; + } + .collection-item-run { border-left: 4px solid #3b82f6 !important; background: rgba(59, 130, 246, 0.12) !important; } + .collection-item-snapshot { border-left: 4px solid #10b981 !important; background: rgba(16, 185, 129, 0.12) !important; } + .collection-item-error { border-left: 4px solid #ef4444 !important; background: rgba(239, 68, 68, 0.12) !important; } + .collection-item code { + display: block; + margin-top: 6px; + white-space: pre-wrap; + color: #cbd5e1; + } pre { margin:0; max-height: 320px; overflow:auto; background: rgba(3,7,18,.45); border: 1px solid rgba(255,255,255,.08); border-radius: 12px; padding:10px; font-size:12px; } @media (max-width: 980px) { .grid, .metrics { grid-template-columns: 1fr; } } @@ -2740,7 +3390,12 @@ def render_collection_html() -> str:
+
Execution / Status / Result
collection: loading...
+
run: idle
+
progress: idle
+
stage: idle
+
result: pending
@@ -2755,6 +3410,26 @@ def render_collection_html() -> str:
+
+ + + + + +
mode: real / live
+
+
Real mode is selected by default. Offline replay is available only when explicitly enabled.
+
live source: unknown
+
Auto refreshes every 15 seconds while this page is open.
+
@@ -2767,17 +3442,21 @@ def render_collection_html() -> str:
Recent collector errors
+
Unified activity timeline
+
-
Collection detail
+
Collection detail

+            
Run log
+

           
@@ -2937,12 +3897,63 @@ def render_tables_html() -> str:
+
+
+
+
+
DB 먼저 / JSON은 증빙
+

DB별 수정, JSON별 검토, 수집 증빙 확인

+
Workspace rows are editable only in the canonical DB. Collection and strategy views are read-only proof surfaces.
+
+ +
+
+ + + + +
+
+ DB 먼저 + JSON은 증빙 + Edit only canonical rows +
+
+
+
+
DB tables
+
Editable source of truth. Only canonical workspace rows are mutated here.
+
+
+
+
+
Workbook sheets
+
Derived report evidence. These sheets summarize DB-backed outputs and run history.
+
+
+
+
version: loading...
+
+ Checking whether the table combo covers the target workbook sheets... +
+
+ Workbook sheets only + + sheet: none + surface: none +
+
+
- + + read only
@@ -2954,31 +3965,162 @@ def render_tables_html() -> str:
+
Save applies only to the canonical workspace DB. This selector is for DB tables only; workbook sheets are handled above.
-
+
+ Workspace + Collection + Strategy + JSON +
+
+ Registry details +
+
+
+
+
Open only when you need the table/sheet mapping.
+
+
+
+
+
DB tables
+
+
+ + + + + + + + + + +
TableDBRowsEdit
+
+
+
+
+
+
+
Derived report registry
+
+
+
+ DB가 원천이고 JSON은 DB 기반 파생 보고서 증빙이다. 이 화면은 그 관계만 보여준다. +
+
+
+ + + + + + + + + + + + + +
SheetDestinationKindSource RolePurposeRowsRecorded surface
+
+
+
+
+
+
+ History details +
Recent workspace change log and collector run history.
+
+
+
+ + + + + +
WhenDomainActionTarget
+
+
+
+
+ + + + + +
RunStatusStartedFinished
+
+
+
+
+
+ Collection purpose: monitor collector runs and snapshots. + Latest run summary loads from `/api/state`. +
+ DB별 / JSON별 조회 기준을 먼저 확인한 뒤 수정하세요. + This page is intentionally a triage surface, not a generic table dump. Workspace tables are editable only when the table is in the canonical workspace DB. Collection and strategy tables are read-only by design unless the backing store explicitly supports editing.
-
-
- Table browser ready - 0 rows - filter=none - page=0 +
+ Workspace and DB view +
Purpose: edit canonical workspace rows only.
+
+
+ Table browser ready + 0 rows + filter=none + page=0 +
+
If a table shows "no rows", the current filter or selected table has no visible records.
-
If a table shows "no rows", the current filter or selected table has no visible records.
-
-
- - - - - - -
-
+
+ + + + + + +
+
+ +
+ Derived JSON evidence view +
Purpose: inspect DB-backed JSON evidence and row payloads.
+
+
+
+
Derived JSON Evidence Preview
+
Latest report JSON generated after DB-backed collection from Temp/kis_data_collection_v1.json
+
+ loading... +
+
+
+
+
+
Select a workbook sheet to inspect its registry detail.
+
No workbook sheet selected.
+
+ + + + + +
+
+
Click a row to inspect the derived JSON payload.
+
No JSON row selected.
+
version: loading...
+
+
+
@@ -2986,6 +4128,8 @@ def render_tables_html() -> str: