From b84024d871cb5889a401d953308c7893489fd26d Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 16:44:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs(model):=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EB=AC=BC=20=EB=B3=B4=EA=B4=80=EA=B3=BC=20?= =?UTF-8?q?=EC=9E=AC=ED=98=84=20=EC=A0=95=EC=B1=85=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++ fowoco-knowledge/README.md | 2 + .../docs/MODEL_ARTIFACT_POLICY.md | 63 ++++++++++++++ fowoco-knowledge/hr-intent-service/README.md | 5 ++ .../hr-intent-service/models/MODEL_CARD.md | 50 +++++++++++ .../models/artifact-manifest.yaml | 55 ++++++++++++ .../hr-intent-service/requirements.txt | 16 ++-- fowoco-knowledge/knowledge/manifest.yaml | 3 + .../model-artifact-manifest.schema.json | 85 +++++++++++++++++++ .../src/fowoco_knowledge/validation.py | 51 +++++++++++ fowoco-knowledge/tests/test_validation.py | 16 ++++ 11 files changed, 344 insertions(+), 8 deletions(-) create mode 100644 fowoco-knowledge/docs/MODEL_ARTIFACT_POLICY.md create mode 100644 fowoco-knowledge/hr-intent-service/models/MODEL_CARD.md create mode 100644 fowoco-knowledge/hr-intent-service/models/artifact-manifest.yaml create mode 100644 fowoco-knowledge/schemas/model-artifact-manifest.schema.json diff --git a/README.md b/README.md index 643f2b8..35a6ad7 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,11 @@ make check 기준 위치는 [FOWOCO Hugging Face](https://huggingface.co/fowoco)이며, 운영 서빙 코드의 장기 소유권은 `fowoco/ai`에 둡니다. +모델 파일의 SHA·학습 데이터 계보·비공개 HF 배포 기준은 +[`MODEL_ARTIFACT_POLICY.md`](fowoco-knowledge/docs/MODEL_ARTIFACT_POLICY.md)와 +[`artifact-manifest.yaml`](fowoco-knowledge/hr-intent-service/models/artifact-manifest.yaml)에 +고정했습니다. 운영 배포는 HF `main`이 아니라 인증 후 확인한 commit SHA를 사용합니다. + ## 안전 원칙과 현재 한계 - 모델 출력만으로 법률·체류·계약·급여·신고 결론을 확정하지 않습니다. @@ -184,3 +189,4 @@ make check - [공식 데이터 파이프라인](fowoco-knowledge/docs/OFFICIAL_DATA_PIPELINE.md) - [E-9 신고·연장 Workflow](fowoco-knowledge/docs/E9_REPORTING_WORKFLOWS.md) - [모델 서빙 실행 방법](fowoco-knowledge/hr-intent-service/README.md) +- [모델 산출물 보관·배포 정책](fowoco-knowledge/docs/MODEL_ARTIFACT_POLICY.md) diff --git a/fowoco-knowledge/README.md b/fowoco-knowledge/README.md index b45b1cf..dea6c0a 100644 --- a/fowoco-knowledge/README.md +++ b/fowoco-knowledge/README.md @@ -20,6 +20,8 @@ Slot·공식 출처·Guardrail을 조회하고, 데이터 계약과 교차참조 모델 가중치의 배포 기준 위치는 [FOWOCO Hugging Face](https://huggingface.co/fowoco)입니다. `hr-intent-service/`는 프로젝트 결과 재현과 AI 저장소 인계를 위한 스냅샷입니다. +파일 SHA·학습 데이터 계보·Secret 관리 기준은 +[`docs/MODEL_ARTIFACT_POLICY.md`](docs/MODEL_ARTIFACT_POLICY.md)를 따릅니다. ## 데이터 기준 diff --git a/fowoco-knowledge/docs/MODEL_ARTIFACT_POLICY.md b/fowoco-knowledge/docs/MODEL_ARTIFACT_POLICY.md new file mode 100644 index 0000000..60ae611 --- /dev/null +++ b/fowoco-knowledge/docs/MODEL_ARTIFACT_POLICY.md @@ -0,0 +1,63 @@ +# Intent 모델 산출물 보관·배포 정책 + +## 한 줄 기준 + +학습 데이터와 검증 기준은 `fowoco/knowledge`, 배포 가중치는 비공개 Hugging Face, +운영 추론 코드는 `fowoco/ai`가 소유한다. 배포는 모델 이름의 `latest`가 아니라 +Hugging Face commit SHA를 고정해서 수행한다. + +## 저장 위치와 책임 + +| 대상 | 기준 위치 | 책임 | +| --- | --- | --- | +| Intent 라벨·학습/검증 데이터 | `fowoco/knowledge` | 데이터 버전·SHA·검수 기준 | +| 제출·재현용 가중치 스냅샷 | Knowledge Git LFS | 파일 무결성과 인계 | +| 배포용 가중치 | `fowoco` Hugging Face private repo | 접근 제어·배포 revision | +| 운영 추론 코드 | `fowoco/ai` | 모델 로딩·fallback·응답 계약 | +| `HF_TOKEN` | 로컬 `.env` 또는 배포 Secret | 배포 운영자 | + +Knowledge Git LFS 파일은 HF 장애나 권한 인계 시 결과를 재현하기 위한 스냅샷이다. +운영 서비스가 GitHub의 대용량 파일을 직접 내려받아 실행하는 경로로 사용하지 않는다. + +## 배포 전 확인 순서 + +1. `python -m fowoco_knowledge validate`로 데이터와 로컬 스냅샷의 SHA·크기를 확인한다. +2. 권한이 있는 계정으로 HF repo의 commit SHA를 조회한다. +3. AI 배포 환경의 모델 경로를 `repo@commit_sha`로 고정한다. +4. `HF_TOKEN`을 저장소가 아닌 Secret으로 주입한다. +5. 같은 문장으로 BERT 기본 경로와 A.X fallback 경로를 각각 Smoke Test한다. +6. 응답의 `modelVersion`, Knowledge 데이터 version·SHA, AI commit을 실행 기록에 남긴다. + +비공개 저장소이므로 인증하지 않은 HF API가 `401`을 반환하는 것은 정상이다. 이때 +revision을 추측하거나 `main`으로 대체하지 않고, 배포 담당자가 인증 후 SHA를 확인한다. + +## 재현 기준 + +- 모델 파일 목록·SHA-256·크기: `hr-intent-service/models/artifact-manifest.yaml` +- 모델 목적·평가·한계: `hr-intent-service/models/MODEL_CARD.md` +- 학습 데이터 기준: `data/intent/manifest.yaml`의 `known_model_training_snapshot` +- 라이브러리 범위: `hr-intent-service/requirements.txt` + +현재 가중치는 Intent 데이터 `1.2.0` snapshot으로 학습됐다. 현재 데이터 `1.2.1`과 +일치하지 않으므로, `1.2.1` 성능으로 표현하거나 재학습된 가중치처럼 배포하면 안 된다. + +## Token 관리 + +- 개인 Read token 또는 배포 전용 최소 권한 token만 사용한다. +- `.env`, GitHub Actions Secret, Kubernetes Secret 이외의 위치에 기록하지 않는다. +- README, Issue, PR, 로그, Docker image layer에 실제 token을 넣지 않는다. +- 유출이 의심되면 즉시 폐기·재발급하고 실행 환경의 Secret을 교체한다. + +## 최소 Smoke Test + +AI 저장소에서 실제 모델을 켠 뒤 다음을 확인한다. + +```text +입력: 응웬반안 체류연장 준비해줘 +기대 Intent: EXPIRY_RENEWAL +기대 Workflow: WF-STY-001 +금지: modelProvider/modelVersion이 stub으로 표시되는 응답 +``` + +정확도 수치는 내부 Validation 268건의 개발 결과다. 독립 Gold Test 또는 운영 성능으로 +표현하지 않으며 법률 판단, Workflow 실행, 외부기관 제출은 모델의 책임이 아니다. diff --git a/fowoco-knowledge/hr-intent-service/README.md b/fowoco-knowledge/hr-intent-service/README.md index 695e8b4..dfd91b3 100644 --- a/fowoco-knowledge/hr-intent-service/README.md +++ b/fowoco-knowledge/hr-intent-service/README.md @@ -67,6 +67,11 @@ HF_TOKEN==0.115.0 -uvicorn[standard]>=0.30.0 -pydantic-settings>=2.5.0 -transformers>=4.46.0 -accelerate -bitsandbytes -peft -torch \ No newline at end of file +fastapi>=0.115.0,<1 +uvicorn[standard]>=0.30.0,<1 +pydantic-settings>=2.5.0,<3 +transformers>=4.46.0,<5 +accelerate>=1,<2 +bitsandbytes>=0.44,<1 +peft>=0.13,<1 +torch>=2.4,<3 diff --git a/fowoco-knowledge/knowledge/manifest.yaml b/fowoco-knowledge/knowledge/manifest.yaml index e317e9d..26039ad 100644 --- a/fowoco-knowledge/knowledge/manifest.yaml +++ b/fowoco-knowledge/knowledge/manifest.yaml @@ -38,6 +38,9 @@ datasets: catalog_e2e_manifest: data/evaluation/e2e_catalog_manifest.yaml catalog_e2e_gold_candidates: data/evaluation/e2e_catalog_cases.jsonl demo_expiry_renewal: data/demo/expiry_renewal_golden.yaml +artifacts: + intent_models: hr-intent-service/models/artifact-manifest.yaml + intent_model_schema: schemas/model-artifact-manifest.schema.json review_policy: minimum_reviewers_for_official_knowledge: 2 all_outbound_messages_require_hr_approval: true diff --git a/fowoco-knowledge/schemas/model-artifact-manifest.schema.json b/fowoco-knowledge/schemas/model-artifact-manifest.schema.json new file mode 100644 index 0000000..57c9c16 --- /dev/null +++ b/fowoco-knowledge/schemas/model-artifact-manifest.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fowoco.dev/schemas/model-artifact-manifest.schema.json", + "title": "FOWOCO intent model artifact manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "manifest_version", + "canonical_distribution", + "training_dataset", + "models", + "secret_policy" + ], + "properties": { + "manifest_version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"}, + "canonical_distribution": {"const": "HUGGING_FACE_PRIVATE"}, + "training_dataset": { + "type": "object", + "additionalProperties": false, + "required": ["version", "sha256", "current_dataset_version", "matches_current_dataset"], + "properties": { + "version": {"type": "string"}, + "sha256": {"$ref": "#/$defs/sha256"}, + "current_dataset_version": {"type": "string"}, + "matches_current_dataset": {"type": "boolean"} + } + }, + "models": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "role", + "hugging_face_repo", + "access", + "revision_policy", + "runtime_owner", + "snapshot_files" + ], + "properties": { + "id": {"type": "string", "pattern": "^[A-Z0-9_]+$"}, + "role": {"enum": ["PRIMARY_CLASSIFIER", "FALLBACK_ADAPTER"]}, + "base_model": {"type": "string", "minLength": 1}, + "hugging_face_repo": {"type": "string", "pattern": "^fowoco/[A-Za-z0-9._-]+$"}, + "access": {"const": "PRIVATE"}, + "revision_policy": {"const": "PIN_COMMIT_SHA_AT_DEPLOYMENT"}, + "runtime_owner": {"const": "fowoco/ai"}, + "snapshot_files": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "bytes"], + "properties": { + "path": { + "type": "string", + "pattern": "^hr-intent-service/models/[A-Za-z0-9._/-]+$" + }, + "sha256": {"$ref": "#/$defs/sha256"}, + "bytes": {"type": "integer", "minimum": 1} + } + } + } + } + } + }, + "secret_policy": { + "type": "object", + "additionalProperties": false, + "required": ["environment_variable", "repository_storage_forbidden", "owner"], + "properties": { + "environment_variable": {"const": "HF_TOKEN"}, + "repository_storage_forbidden": {"const": true}, + "owner": {"const": "deployment-operator"} + } + } + }, + "$defs": { + "sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"} + } +} diff --git a/fowoco-knowledge/src/fowoco_knowledge/validation.py b/fowoco-knowledge/src/fowoco_knowledge/validation.py index 3beee62..9baffa1 100644 --- a/fowoco-knowledge/src/fowoco_knowledge/validation.py +++ b/fowoco-knowledge/src/fowoco_knowledge/validation.py @@ -99,6 +99,7 @@ def validate_all(self) -> list[str]: self._validate_catalog_e2e_data() self._validate_intent_data() self._validate_intent_split() + self._validate_model_artifacts() return self.errors def _validate_manifest_files(self) -> None: @@ -109,6 +110,56 @@ def _validate_manifest_files(self) -> None: for key, relative_path in manifest.get("datasets", {}).items(): if not (self.repository.root / relative_path).is_file(): self.errors.append(f"manifest dataset missing: {key} -> {relative_path}") + for key, relative_path in manifest.get("artifacts", {}).items(): + if not (self.repository.root / relative_path).is_file(): + self.errors.append(f"manifest artifact missing: {key} -> {relative_path}") + + def _validate_model_artifacts(self) -> None: + relative_path = self.repository.manifest.get("artifacts", {}).get("intent_models") + schema_path = self.repository.manifest.get("artifacts", {}).get("intent_model_schema") + if not relative_path or not schema_path: + return + + manifest = self.repository.load_yaml(relative_path) + schema = self.repository.load_json(schema_path) + schema_errors = list(Draft202012Validator(schema).iter_errors(manifest)) + for error in schema_errors: + path = ".".join(str(item) for item in error.path) + self.errors.append(f"model artifact schema [{path}]: {error.message}") + if schema_errors: + return + + intent_manifest = self.repository.load_yaml("data/intent/manifest.yaml") + known_snapshot = intent_manifest["known_model_training_snapshot"] + training_dataset = manifest["training_dataset"] + if training_dataset["version"] != known_snapshot["dataset_version"]: + self.errors.append("model artifact: training dataset version mismatch") + if training_dataset["sha256"] != known_snapshot["sha256"]: + self.errors.append("model artifact: training dataset checksum mismatch") + if training_dataset["current_dataset_version"] != intent_manifest["version"]: + self.errors.append("model artifact: current dataset version mismatch") + if training_dataset["matches_current_dataset"] != known_snapshot["matches_current_dataset"]: + self.errors.append("model artifact: current dataset match flag mismatch") + + seen_model_ids: set[str] = set() + seen_paths: set[str] = set() + for model in manifest["models"]: + if model["id"] in seen_model_ids: + self.errors.append(f"model artifact: duplicate model id {model['id']}") + seen_model_ids.add(model["id"]) + for artifact in model["snapshot_files"]: + artifact_path = artifact["path"] + if artifact_path in seen_paths: + self.errors.append(f"model artifact: duplicate file {artifact_path}") + seen_paths.add(artifact_path) + path = self.repository.root / artifact_path + if not path.is_file(): + self.errors.append(f"model artifact: missing file {artifact_path}") + continue + if path.stat().st_size != artifact["bytes"]: + self.errors.append(f"model artifact: size mismatch {artifact_path}") + if file_sha256(path) != artifact["sha256"]: + self.errors.append(f"model artifact: checksum mismatch {artifact_path}") def _validate_processed_datasets(self) -> None: processed_manifest_path = self.repository.root / "data/processed/manifest.yaml" diff --git a/fowoco-knowledge/tests/test_validation.py b/fowoco-knowledge/tests/test_validation.py index b09be06..c2261e4 100644 --- a/fowoco-knowledge/tests/test_validation.py +++ b/fowoco-knowledge/tests/test_validation.py @@ -147,3 +147,19 @@ def test_intent_split_uses_final_data_without_overlap_or_missing_ids() -> None: assert len(validation_ids) == 268 assert train_ids.isdisjoint(validation_ids) assert train_ids | validation_ids == {case["id"] for case in final_cases} + + +def test_model_artifact_manifest_pins_snapshot_and_runtime_ownership() -> None: + manifest = yaml.safe_load( + (ROOT / "hr-intent-service/models/artifact-manifest.yaml").read_text(encoding="utf-8") + ) + + assert manifest["canonical_distribution"] == "HUGGING_FACE_PRIVATE" + assert manifest["training_dataset"]["version"] == "1.2.0" + assert manifest["training_dataset"]["matches_current_dataset"] is False + assert {model["runtime_owner"] for model in manifest["models"]} == {"fowoco/ai"} + assert {model["revision_policy"] for model in manifest["models"]} == { + "PIN_COMMIT_SHA_AT_DEPLOYMENT" + } + assert all(model["snapshot_files"] for model in manifest["models"]) + assert manifest["secret_policy"]["repository_storage_forbidden"] is True From b358a9a9a61b5bd69a36b098f6efa85b67d04e6d Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 16:46:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(model):=20CI=EC=97=90=EC=84=9C=20Git=20?= =?UTF-8?q?LFS=20=ED=8F=AC=EC=9D=B8=ED=84=B0=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/fowoco_knowledge/validation.py | 29 +++++++++++++++++++ fowoco-knowledge/tests/test_validation.py | 22 +++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/fowoco-knowledge/src/fowoco_knowledge/validation.py b/fowoco-knowledge/src/fowoco_knowledge/validation.py index 9baffa1..da100e6 100644 --- a/fowoco-knowledge/src/fowoco_knowledge/validation.py +++ b/fowoco-knowledge/src/fowoco_knowledge/validation.py @@ -4,6 +4,7 @@ import json import re from collections import Counter +from pathlib import Path from typing import Any from jsonschema import Draft202012Validator @@ -67,6 +68,12 @@ "passport_number": re.compile(r"(? list[str]: """Return machine-facing identifiers exposed in user-facing text.""" @@ -82,6 +89,20 @@ def split_codes(raw: str | None) -> list[str]: return [item.strip() for item in (raw or "").split("|") if item.strip()] +def read_git_lfs_pointer(path: Path) -> tuple[str, int] | None: + """Read an LFS pointer without loading a potentially large model into memory.""" + if path.stat().st_size > 256: + return None + try: + content = path.read_text(encoding="ascii") + except UnicodeDecodeError: + return None + match = GIT_LFS_POINTER_PATTERN.fullmatch(content) + if not match: + return None + return match.group(1), int(match.group(2)) + + class KnowledgeValidator: def __init__(self, repository: KnowledgeRepository) -> None: self.repository = repository @@ -156,6 +177,14 @@ def _validate_model_artifacts(self) -> None: if not path.is_file(): self.errors.append(f"model artifact: missing file {artifact_path}") continue + lfs_pointer = read_git_lfs_pointer(path) + if lfs_pointer: + pointer_sha256, pointer_bytes = lfs_pointer + if pointer_bytes != artifact["bytes"]: + self.errors.append(f"model artifact: LFS size mismatch {artifact_path}") + if pointer_sha256 != artifact["sha256"]: + self.errors.append(f"model artifact: LFS checksum mismatch {artifact_path}") + continue if path.stat().st_size != artifact["bytes"]: self.errors.append(f"model artifact: size mismatch {artifact_path}") if file_sha256(path) != artifact["sha256"]: diff --git a/fowoco-knowledge/tests/test_validation.py b/fowoco-knowledge/tests/test_validation.py index c2261e4..abf38e4 100644 --- a/fowoco-knowledge/tests/test_validation.py +++ b/fowoco-knowledge/tests/test_validation.py @@ -8,7 +8,12 @@ import yaml from fowoco_knowledge.repository import KnowledgeRepository -from fowoco_knowledge.validation import KnowledgeValidator, find_internal_keys, split_codes +from fowoco_knowledge.validation import ( + KnowledgeValidator, + find_internal_keys, + read_git_lfs_pointer, + split_codes, +) ROOT = Path(__file__).resolve().parents[1] @@ -163,3 +168,18 @@ def test_model_artifact_manifest_pins_snapshot_and_runtime_ownership() -> None: } assert all(model["snapshot_files"] for model in manifest["models"]) assert manifest["secret_policy"]["repository_storage_forbidden"] is True + + +def test_git_lfs_pointer_exposes_artifact_checksum_and_size(tmp_path: Path) -> None: + pointer = tmp_path / "model.safetensors" + pointer.write_text( + "version https://git-lfs.github.com/spec/v1\n" + "oid sha256:abf0d7d3bdb89a5ac34abc8cc41a77396bf024f02de6939591b3c555446bd48b\n" + "size 442518124\n", + encoding="ascii", + ) + + assert read_git_lfs_pointer(pointer) == ( + "abf0d7d3bdb89a5ac34abc8cc41a77396bf024f02de6939591b3c555446bd48b", + 442518124, + )