From 13e653aae781b49ab703d27cbc0865dbdc8b3230 Mon Sep 17 00:00:00 2001 From: HWIYA Date: Mon, 10 Aug 2026 10:10:28 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20=EC=97=AC=EA=B6=8C=C2=B7=EC=99=B8?= =?UTF-8?q?=EA=B5=AD=EC=9D=B8=EB=93=B1=EB=A1=9D=EC=A6=9D=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=8B=9C=20=EA=B7=BC=EB=A1=9C=EC=9E=90=EC=97=90?= =?UTF-8?q?=EA=B2=8C=20=EC=9A=94=EC=B2=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agents/pipeline.py | 9 +++-- app/agents/workflow/service.py | 7 +++- .../workflow_graph/document_validation.py | 30 +++++++++++----- .../workflow_graph/nodes/language_stub.py | 4 +-- app/agents/workflow_graph/state.py | 5 ++- app/agents/workflow_graph/supervisor.py | 6 ++-- tests/agents/test_analysis_pipeline.py | 34 ++++++++++++++++++ tests/agents/test_supervisor.py | 35 +++++++++++++++++++ 8 files changed, 113 insertions(+), 17 deletions(-) diff --git a/app/agents/pipeline.py b/app/agents/pipeline.py index 908ad81..61dff22 100644 --- a/app/agents/pipeline.py +++ b/app/agents/pipeline.py @@ -22,6 +22,7 @@ from .ambiguity import AmbiguityAgent from .intent import IntentClassifier, build_intent_agent from .workflow import WorkflowAgent +from .workflow_graph.state import HR_EXCLUDED_SLOTS # instruction 끝의 `, INTENT_TAG` 제거 _INTENT_TAG_SUFFIX = re.compile(r",\s*[A-Z][A-Z0-9_]+\s*$") @@ -194,12 +195,16 @@ def _run_analyze(self, request: AnalysisRequest) -> AnalysisResponse: # Server가 못 채운 PLAN 요청 키 → HR 질문 후보 hr_keys: list[str] = [] for key in ai.requested_field_keys: - if key not in worker.requested_fields and key not in slots: + if ( + key not in HR_EXCLUDED_SLOTS + and key not in worker.requested_fields + and key not in slots + ): hr_keys.append(key) amb = self._ambiguity.check(workflow_id, slots, instruction) for key in amb.missing_slots: - if key not in slots and key not in hr_keys: + if key not in HR_EXCLUDED_SLOTS and key not in slots and key not in hr_keys: hr_keys.append(key) if hr_keys: diff --git a/app/agents/workflow/service.py b/app/agents/workflow/service.py index cef2960..f704a43 100644 --- a/app/agents/workflow/service.py +++ b/app/agents/workflow/service.py @@ -16,7 +16,12 @@ "name": "체류기간 연장 준비", "intent": "EXPIRY_RENEWAL", "sensitivity": "high", - "required_slots": ["worker_id", "stay_expiry_date"], + "required_slots": [ + "worker_id", + "stay_expiry_date", + "passport_status", + "arc_status", + ], "input_modes": ["AGENT_TASK", "INTERNAL_REQUEST"], }, "WF-CON-001": { diff --git a/app/agents/workflow_graph/document_validation.py b/app/agents/workflow_graph/document_validation.py index c092a94..b14a6ea 100644 --- a/app/agents/workflow_graph/document_validation.py +++ b/app/agents/workflow_graph/document_validation.py @@ -47,18 +47,32 @@ def _presence_from_docs_and_slots( def has_slot(keys: tuple[str, ...]) -> bool: return any(slots.get(k) or ocr.get(k) for k in keys) - passport: DocPresence = "unknown" - if any("passport" in t or "여권" in t for t in types) or has_slot(passport_keys): + def from_status(key: str) -> DocPresence: + status = str(slots.get(key) or "").strip().upper() + if status == "MISSING": + return "missing" + if status in {"SUBMITTED", "VERIFIED"}: + return "present" + return "unknown" + + passport: DocPresence = from_status("passport_status") + if passport == "unknown" and ( + any("passport" in t or "여권" in t for t in types) or has_slot(passport_keys) + ): passport = "present" - elif "passport_number" in _explicit_missing(slots): + elif passport == "unknown" and "passport_number" in _explicit_missing(slots): passport = "missing" - alien: DocPresence = "unknown" - if any( - "alien" in t or "registration" in t or "등록증" in t or "arc" in t for t in types - ) or has_slot(alien_keys): + alien: DocPresence = from_status("arc_status") + if alien == "unknown" and ( + any( + "alien" in t or "registration" in t or "등록증" in t or "arc" in t + for t in types + ) + or has_slot(alien_keys) + ): alien = "present" - elif "alien_registration_number" in _explicit_missing(slots): + elif alien == "unknown" and "alien_registration_number" in _explicit_missing(slots): alien = "missing" # 신분 슬롯이 비어 있고 관련 문서도 없으면 missing으로 간주 diff --git a/app/agents/workflow_graph/nodes/language_stub.py b/app/agents/workflow_graph/nodes/language_stub.py index 90b2291..2bfca43 100644 --- a/app/agents/workflow_graph/nodes/language_stub.py +++ b/app/agents/workflow_graph/nodes/language_stub.py @@ -7,7 +7,7 @@ from app.agents.ambiguity import AmbiguityAgent from app.agents.intent import IntentClassifier, build_intent_agent -from ..state import IDENTITY_SLOTS, RenewalState +from ..state import HR_EXCLUDED_SLOTS, IDENTITY_SLOTS, RenewalState # 담당자 입력 — 클라이언트가 채우는 계약·근무 슬롯. CONTRACT_SLOTS: tuple[str, ...] = ( @@ -53,7 +53,7 @@ def __call__(self, state: RenewalState) -> dict[str, Any]: workflow_id = result.workflow_id or "UNKNOWN" amb = self._ambiguity.check(workflow_id, slots, state["instruction"]) - missing = list(amb.missing_slots) + missing = [key for key in amb.missing_slots if key not in HR_EXCLUDED_SLOTS] # 재갱신: 신분(근로자 서류) + 계약(담당자 입력) 슬롯 누락 함께 확인 if result.intent == "EXPIRY_RENEWAL": diff --git a/app/agents/workflow_graph/state.py b/app/agents/workflow_graph/state.py index 02d7073..8541af2 100644 --- a/app/agents/workflow_graph/state.py +++ b/app/agents/workflow_graph/state.py @@ -4,7 +4,6 @@ from typing import Any, NotRequired, TypedDict - # 여권/외국인등록증 OCR로 채우는 신분 항목 IDENTITY_SLOTS: frozenset[str] = frozenset( { @@ -16,6 +15,10 @@ } ) +HR_EXCLUDED_SLOTS: frozenset[str] = frozenset( + {"passport_status", "arc_status", "arc_expiry_date"} +) + # 재갱신 한 건의 진행 상태 (노드·서브그래프 간 공유) class RenewalState(TypedDict): diff --git a/app/agents/workflow_graph/supervisor.py b/app/agents/workflow_graph/supervisor.py index 17c5b5d..fadac5a 100644 --- a/app/agents/workflow_graph/supervisor.py +++ b/app/agents/workflow_graph/supervisor.py @@ -12,7 +12,7 @@ from .document_validation import DocumentValidation, validate_identity_documents from .phases import WorkflowPhase, WorkflowStep -from .state import IDENTITY_SLOTS +from .state import HR_EXCLUDED_SLOTS, IDENTITY_SLOTS logger = logging.getLogger(__name__) @@ -56,7 +56,7 @@ def decide_route_rules(state: dict[str, Any]) -> SupervisorDecision: validation = validate_identity_documents(state) missing = list(state.get("missing_slots") or []) identity_missing = [m for m in missing if m in IDENTITY_SLOTS] - other_missing = [m for m in missing if m not in IDENTITY_SLOTS] + other_missing = [m for m in missing if m not in IDENTITY_SLOTS | HR_EXCLUDED_SLOTS] has_docs = bool(state.get("documents")) has_ocr = bool(state.get("ocr_result")) @@ -103,7 +103,7 @@ def decide_route_rules(state: dict[str, Any]) -> SupervisorDecision: case_signals=tuple(signals), ) - if other_missing or missing: + if other_missing: return SupervisorDecision( route="ask_hr", phase=WorkflowPhase.VALIDATION_COMMUNICATION, diff --git a/tests/agents/test_analysis_pipeline.py b/tests/agents/test_analysis_pipeline.py index 6973999..ee56b17 100644 --- a/tests/agents/test_analysis_pipeline.py +++ b/tests/agents/test_analysis_pipeline.py @@ -44,6 +44,40 @@ def test_plan_returns_context_required_for_expiry() -> None: assert res.context_requirement is not None assert res.context_requirement.detected_intent == "EXPIRY_RENEWAL" assert "worker_id" in res.context_requirement.required_field_keys + assert "passport_status" in res.context_requirement.required_field_keys + assert "arc_status" in res.context_requirement.required_field_keys + + +def test_analyze_does_not_ask_hr_for_document_managed_fields() -> None: + pipe = AnalysisPipeline( + intent_agent=_FakeIntent(intent="EXPIRY_RENEWAL", workflow_id="WF-STY-001") + ) + worker = WorkerContext( + workerRef="30000000-0000-0000-0000-000000000001", + requestedFields={ + "worker_id": "30000000-0000-0000-0000-000000000001", + "stay_expiry_date": "2026-12-31", + }, + ) + req = AnalysisRequest( + requestId=str(uuid4()), + phase="ANALYZE", + analysisInput=AnalysisInput( + instruction="체류 연장", + requestedFieldKeys=[ + "worker_id", + "passport_status", + "arc_status", + "arc_expiry_date", + ], + workers=[worker], + ), + ) + + res = pipe.run(req) + + assert res.outcome == "REVIEW_REQUIRED" + assert res.questions == [] # OUT_OF_SCOPE PLAN은 worker_id만 요청 diff --git a/tests/agents/test_supervisor.py b/tests/agents/test_supervisor.py index 99ffb8a..f141751 100644 --- a/tests/agents/test_supervisor.py +++ b/tests/agents/test_supervisor.py @@ -30,6 +30,41 @@ def test_passport_only_requests_alien() -> None: assert "REQUEST_ALIEN_REGISTRATION" in decision.case_signals +def test_arc_missing_status_routes_ask_worker() -> None: + state = empty_renewal_state( + task_id="t", request_id="r", instruction="연장", worker_id="w1" + ) + state["intent"] = "EXPIRY_RENEWAL" + state["slots"] = { + "passport_status": "VERIFIED", + "arc_status": "MISSING", + "arc_expiry_date": "", + } + state["missing_slots"] = ["arc_expiry_date"] + + decision = decide_route_rules(state) + + assert decision.route == "ask_worker" + assert "REQUEST_ALIEN_REGISTRATION" in decision.case_signals + assert "REQUEST_PASSPORT" not in decision.case_signals + + +def test_document_statuses_present_do_not_route_ask_worker() -> None: + state = empty_renewal_state( + task_id="t", request_id="r", instruction="연장", worker_id="w1" + ) + state["intent"] = "EXPIRY_RENEWAL" + state["slots"] = { + "passport_status": "SUBMITTED", + "arc_status": "VERIFIED", + } + state["missing_slots"] = ["arc_expiry_date"] + + decision = decide_route_rules(state) + + assert decision.route == "generate" + + def test_documents_route_ocr() -> None: state = empty_renewal_state( task_id="t", request_id="r", instruction="연장", worker_id="w1" From b54c36cff08e4a7ba68e9c61a2a5daafaea0b3cc Mon Sep 17 00:00:00 2001 From: HWIYA Date: Mon, 10 Aug 2026 15:51:07 +0900 Subject: [PATCH 02/21] =?UTF-8?q?fix:=20analyses=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20=EC=8B=A4=EC=A0=9C=20Intent=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agents/intent/service.py | 21 ++++++++++++++++++++- app/agents/pipeline.py | 15 +++++++++------ app/api/schemas/analyses.py | 6 +++--- tests/agents/test_analysis_pipeline.py | 6 ++++++ tests/agents/test_intent_agent.py | 3 +++ tests/agents/test_intent_hybrid.py | 6 ++++++ tests/api/test_analyses_endpoint.py | 3 +++ 7 files changed, 50 insertions(+), 10 deletions(-) diff --git a/app/agents/intent/service.py b/app/agents/intent/service.py index 82c3aa0..4c46850 100644 --- a/app/agents/intent/service.py +++ b/app/agents/intent/service.py @@ -85,6 +85,9 @@ class IntentResult: intent: str confidence: float workflow_id: str + model_provider: str + model_name: str + model_version: str extracted_slots: dict[str, str] = field(default_factory=dict) @@ -116,6 +119,9 @@ def classify( intent=intent, confidence=1.0, workflow_id=resolve_workflow_id(intent, workflow_constraints), + model_provider="internal", + model_name="fixed-expiry-renewal", + model_version="rules", extracted_slots={}, ) @@ -187,11 +193,21 @@ def classify( ) -> IntentResult: pipeline = self._ensure_pipeline() if pipeline is None: - return FixedExpiryRenewalIntentAgent().classify( + result = FixedExpiryRenewalIntentAgent().classify( instruction, workflow_constraints=workflow_constraints ) + result.model_version = "fallback" + return result prediction = pipeline.predict(instruction) # type: ignore[attr-defined] intent, confidence = _primary_intent(prediction.intents, prediction.scores) + from app.core.config import get_settings + + settings = get_settings() + model_name = ( + settings.intent_ax_base_model + if prediction.selected_model == "AX" + else settings.intent_bert_model_dir + ) slots: dict[str, str] = {} for name, evidence in (prediction.evidence or {}).items(): if evidence: @@ -201,6 +217,9 @@ def classify( confidence=max(0.0, min(1.0, confidence)), workflow_id=resolve_workflow_id(intent, workflow_constraints), extracted_slots=slots, + model_provider="huggingface", + model_name=model_name, + model_version=prediction.selected_model, ) diff --git a/app/agents/pipeline.py b/app/agents/pipeline.py index 908ad81..4c66aa9 100644 --- a/app/agents/pipeline.py +++ b/app/agents/pipeline.py @@ -20,7 +20,7 @@ ) from .ambiguity import AmbiguityAgent -from .intent import IntentClassifier, build_intent_agent +from .intent import IntentClassifier, IntentResult, build_intent_agent from .workflow import WorkflowAgent # instruction 끝의 `, INTENT_TAG` 제거 @@ -102,9 +102,12 @@ def _seed_slots_from_worker(worker: WorkerContext) -> dict[str, str]: # 고정 버전 블록 -def _versions() -> AnalysisVersions: +def _versions(intent_result: IntentResult) -> AnalysisVersions: return AnalysisVersions( agent_version=__version__, + model_provider=intent_result.model_provider, + model_name=intent_result.model_name, + model_version=intent_result.model_version, contract_version=DEFAULT_CONTRACT_VERSION, workflow_catalog_version=DEFAULT_KNOWLEDGE_VERSION, context_pack_version=DEFAULT_KNOWLEDGE_VERSION, @@ -162,7 +165,7 @@ def _run_plan(self, request: AnalysisRequest) -> AnalysisResponse: questions=[], candidates=[], validation_errors=[], - versions=_versions(), + versions=_versions(intent_result), provider_attempt_count=1, latency_ms=0, ) @@ -182,7 +185,7 @@ def _run_analyze(self, request: AnalysisRequest) -> AnalysisResponse: questions=[_question_for("worker_id")], candidates=[], validation_errors=[], - versions=_versions(), + versions=_versions(intent_result), provider_attempt_count=1, latency_ms=0, ) @@ -210,7 +213,7 @@ def _run_analyze(self, request: AnalysisRequest) -> AnalysisResponse: questions=[_question_for(k) for k in hr_keys], candidates=[], validation_errors=[], - versions=_versions(), + versions=_versions(intent_result), provider_attempt_count=1, latency_ms=0, ) @@ -231,7 +234,7 @@ def _run_analyze(self, request: AnalysisRequest) -> AnalysisResponse: questions=[], candidates=[candidate], validation_errors=[], - versions=_versions(), + versions=_versions(intent_result), provider_attempt_count=1, latency_ms=0, ) diff --git a/app/api/schemas/analyses.py b/app/api/schemas/analyses.py index 91355ee..602236d 100644 --- a/app/api/schemas/analyses.py +++ b/app/api/schemas/analyses.py @@ -96,9 +96,9 @@ class AnalysisCandidate(BaseModel): class AnalysisVersions(BaseModel): agent_version: str = Field(..., alias="agentVersion") - model_provider: str = Field("stub", alias="modelProvider") - model_name: str = Field("stub", alias="modelName") - model_version: str = Field("stub", alias="modelVersion") + model_provider: str = Field(..., alias="modelProvider") + model_name: str = Field(..., alias="modelName") + model_version: str = Field(..., alias="modelVersion") prompt_version: str = Field("prompt-1", alias="promptVersion") context_pack_version: str = Field(DEFAULT_KNOWLEDGE_VERSION, alias="contextPackVersion") workflow_catalog_version: str = Field( diff --git a/tests/agents/test_analysis_pipeline.py b/tests/agents/test_analysis_pipeline.py index 6973999..8c5775b 100644 --- a/tests/agents/test_analysis_pipeline.py +++ b/tests/agents/test_analysis_pipeline.py @@ -25,6 +25,9 @@ def classify( intent=self.intent, confidence=self.confidence, workflow_id=self.workflow_id, + model_provider="test", + model_name="fake-intent", + model_version="1", extracted_slots={}, ) @@ -43,6 +46,9 @@ def test_plan_returns_context_required_for_expiry() -> None: assert res.outcome == "CONTEXT_REQUIRED" assert res.context_requirement is not None assert res.context_requirement.detected_intent == "EXPIRY_RENEWAL" + assert res.versions.model_provider == "test" + assert res.versions.model_name == "fake-intent" + assert res.versions.model_version == "1" assert "worker_id" in res.context_requirement.required_field_keys diff --git a/tests/agents/test_intent_agent.py b/tests/agents/test_intent_agent.py index 8e57a02..0d01379 100644 --- a/tests/agents/test_intent_agent.py +++ b/tests/agents/test_intent_agent.py @@ -11,6 +11,9 @@ def test_fixed_expiry_renewal_ignores_unrelated_text() -> None: assert result.confidence == 1.0 assert result.workflow_id == "WF-STY-001" assert result.extracted_slots == {} + assert result.model_provider == "internal" + assert result.model_name == "fixed-expiry-renewal" + assert result.model_version == "rules" def test_fixed_does_not_extract_slots_from_instruction() -> None: diff --git a/tests/agents/test_intent_hybrid.py b/tests/agents/test_intent_hybrid.py index eedf66c..dec1b0a 100644 --- a/tests/agents/test_intent_hybrid.py +++ b/tests/agents/test_intent_hybrid.py @@ -53,6 +53,9 @@ def predict(self, instruction: str) -> HybridIntentPrediction: assert result.intent == "EXPIRY_RENEWAL" assert result.confidence == 0.93 assert result.workflow_id == "WF-STY-001" + assert result.model_provider == "huggingface" + assert result.model_name == get_settings().intent_bert_model_dir + assert result.model_version == "BERT" # INTENT_MODEL_ENABLED=true → HybridHfIntentAgent @@ -75,6 +78,9 @@ def _boom(*_args, **_kwargs): # noqa: ANN002, ANN003 agent = HybridHfIntentAgent() result = agent.classify("체류연장 준비해줘") assert result.intent == "EXPIRY_RENEWAL" + assert result.model_provider == "internal" + assert result.model_name == "fixed-expiry-renewal" + assert result.model_version == "fallback" assert agent._load_error is not None diff --git a/tests/api/test_analyses_endpoint.py b/tests/api/test_analyses_endpoint.py index 8b1868e..b35bc32 100644 --- a/tests/api/test_analyses_endpoint.py +++ b/tests/api/test_analyses_endpoint.py @@ -62,6 +62,9 @@ async def test_plan_returns_context_required() -> None: assert "worker_id" in ctx["requiredFieldKeys"] assert data["versions"]["contractVersion"] == "1.0.0" assert data["versions"]["workflowCatalogVersion"] == "0.2.0" + assert data["versions"]["modelProvider"] != "stub" + assert data["versions"]["modelName"] != "stub" + assert data["versions"]["modelVersion"] != "stub" assert "attemptId" not in data From 177e6955d046f2ec8a65f8191ac91126817dc023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A0=95?= Date: Mon, 10 Aug 2026 16:24:10 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat(language):=20Language=20Assistant=20?= =?UTF-8?q?=EB=9F=B0=ED=83=80=EC=9E=84=EA=B3=BC=20Ollama=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agents/language/composition.py | 164 ++++++++++++++ app/agents/language/generation/__init__.py | 2 + app/agents/language/generation/ollama.py | 142 ++++++++++++ app/api/dependencies.py | 18 +- app/core/config.py | 3 +- .../T13-RUNTIME-COMPOSITION-EVIDENCE.md | 204 +++++++++++++++++ .../language/test_ollama_generation_port.py | 126 +++++++++++ tests/agents/language/test_runtime_config.py | 15 ++ tests/api/test_language_endpoint.py | 210 ++++++++++++++++++ .../language/test_runtime_composition.py | 128 +++++++++++ 10 files changed, 1006 insertions(+), 6 deletions(-) create mode 100644 app/agents/language/composition.py create mode 100644 app/agents/language/generation/ollama.py create mode 100644 docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md create mode 100644 tests/agents/language/test_ollama_generation_port.py create mode 100644 tests/integration/language/test_runtime_composition.py diff --git a/app/agents/language/composition.py b/app/agents/language/composition.py new file mode 100644 index 0000000..c2f77ad --- /dev/null +++ b/app/agents/language/composition.py @@ -0,0 +1,164 @@ +"""Lazy runtime composition for the Language Assistant.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from urllib.parse import urlsplit + +from app.agents.language.contracts import ( + LanguageExecutionPolicy, + SupportedLanguage, + WarningCode, + WarningItem, +) +from app.agents.language.generation.models import StructuredGenerator +from app.agents.language.generation.ollama import OllamaGenerationPort +from app.agents.language.generation.openai_compatible import ( + OpenAICompatibleGenerationPort, +) +from app.agents.language.graph import build_language_assistant_graph +from app.agents.language.ports import ( + EpsRetriever, + NoopTraceSink, + SemanticValidationPort, + TraceSink, +) +from app.agents.language.queries import SearchQuery +from app.agents.language.retrieval.models import RetrievalResult +from app.agents.language.service import LanguageAssistantService +from app.agents.language.validation import GeneratedSemanticValidator +from app.core.config import Settings + +_SUPPORTED_LLM_PROVIDERS = frozenset({"openai-compatible", "ollama"}) + + +@dataclass(frozen=True) +class LanguageAssistantCompositionOverrides: + """Complete port set for deterministic tests or alternate runtimes.""" + + generator: StructuredGenerator + retriever: EpsRetriever + semantic_validator: SemanticValidationPort + trace_sink: TraceSink + execution_policy: LanguageExecutionPolicy + + +class LanguageAssistantCompositionUnavailable(RuntimeError): + code = "LANGUAGE_ASSISTANT_COMPOSITION_UNAVAILABLE" + + +class _UnavailableRetriever(EpsRetriever): + """Keep generation usable while optional EPS retrieval is unavailable.""" + + def __init__(self, message: str) -> None: + self._message = message + + def retrieve( + self, + *, + queries: Sequence[SearchQuery], + standard_korean_text: str, + target_language: SupportedLanguage, + ) -> RetrievalResult: + del standard_korean_text, target_language + return RetrievalResult( + dataset_version=None, + query_strategies=tuple(query.kind for query in queries), + contexts=(), + warnings=( + WarningItem( + component="retrieval", + code=WarningCode.RETRIEVAL_UNAVAILABLE, + message=self._message, + ), + ), + fallback_used=True, + degraded_components=("retrieval",), + ) + + +def _required_generation_settings(settings: Settings) -> tuple[str, str, str]: + provider = (settings.llm_provider or "").strip().lower() + base_url = (settings.llm_base_url or "").strip() + model = (settings.llm_model or "").strip() + + if provider not in _SUPPORTED_LLM_PROVIDERS: + raise LanguageAssistantCompositionUnavailable("unsupported LLM provider") + if not base_url: + raise LanguageAssistantCompositionUnavailable("LLM base URL is not configured") + parsed = urlsplit(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise LanguageAssistantCompositionUnavailable("invalid LLM base URL") + if not model: + raise LanguageAssistantCompositionUnavailable("LLM model is not configured") + return provider, base_url, model + + +def _build_production_ports( + settings: Settings, +) -> tuple[ + StructuredGenerator, + EpsRetriever, + SemanticValidationPort, + TraceSink, + LanguageExecutionPolicy, +]: + provider, base_url, model = _required_generation_settings(settings) + generator_type = ( + OllamaGenerationPort if provider == "ollama" else OpenAICompatibleGenerationPort + ) + generator = generator_type( + base_url=base_url, + api_key=settings.llm_api_key, + model=model, + timeout_seconds=settings.llm_timeout_seconds, + ) + retrieval_message = ( + "Qdrant is not configured" + if not settings.qdrant_url + else "Qdrant/BGE retrieval adapter is unavailable" + ) + retriever = _UnavailableRetriever(retrieval_message) + return ( + generator, + retriever, + GeneratedSemanticValidator(generator), + NoopTraceSink(), + LanguageExecutionPolicy(), + ) + + +def build_language_assistant_service( + settings: Settings, + *, + overrides: LanguageAssistantCompositionOverrides | None = None, +) -> LanguageAssistantService: + """Build service without network or model work during module import.""" + if overrides is None: + ports = _build_production_ports(settings) + else: + ports = ( + overrides.generator, + overrides.retriever, + overrides.semantic_validator, + overrides.trace_sink, + overrides.execution_policy, + ) + + generator, retriever, validator, trace_sink, policy = ports + graph = build_language_assistant_graph( + retriever=retriever, + generator=generator, + semantic_validator=validator, + trace_sink=trace_sink, + execution_policy=policy, + ) + return LanguageAssistantService(graph) + + +__all__ = [ + "LanguageAssistantCompositionOverrides", + "LanguageAssistantCompositionUnavailable", + "build_language_assistant_service", +] diff --git a/app/agents/language/generation/__init__.py b/app/agents/language/generation/__init__.py index 4408e5a..e5f4ed6 100644 --- a/app/agents/language/generation/__init__.py +++ b/app/agents/language/generation/__init__.py @@ -5,6 +5,7 @@ StructuredGenerator, TranslationDraft, ) +from .ollama import OllamaGenerationPort from .openai_compatible import ( GenerationError, GenerationHTTPError, @@ -23,6 +24,7 @@ "GenerationSchemaError", "GenerationTransportError", "OpenAICompatibleGenerationPort", + "OllamaGenerationPort", "SemanticValidationDraft", "StructuredGenerator", "TranslationDraft", diff --git a/app/agents/language/generation/ollama.py b/app/agents/language/generation/ollama.py new file mode 100644 index 0000000..7dadc0d --- /dev/null +++ b/app/agents/language/generation/ollama.py @@ -0,0 +1,142 @@ +import json +from collections.abc import Mapping + +import httpx + +from app.agents.language.generation.models import DraftT +from app.agents.language.generation.openai_compatible import ( + GenerationError, + GenerationHTTPError, + GenerationResponseTooLargeError, + GenerationSchemaError, + GenerationTransportError, + _sanitize_payload, +) +from app.agents.language.ports import GenerationOperation, StructuredGenerationPort +from app.agents.language.resources.prompts import load_prompt + + +def _strip_single_json_code_fence(content: str) -> str: + stripped = content.strip() + lines = stripped.splitlines() + if len(lines) < 3 or lines[-1].strip() != "```": + return stripped + if lines[0].strip().lower() not in {"```", "```json"}: + return stripped + return "\n".join(lines[1:-1]).strip() + + +class OllamaGenerationPort(StructuredGenerationPort): + def __init__( + self, + *, + base_url: str, + model: str, + api_key: str | None = None, + timeout_seconds: float = 30.0, + transport: httpx.BaseTransport | None = None, + ) -> None: + native_base_url = base_url.rstrip("/") + if native_base_url.endswith("/v1"): + native_base_url = native_base_url[:-3] + self.base_url = native_base_url + self.model = model + self.api_key = api_key + self.timeout_seconds = timeout_seconds + self._transport = transport + + def get_system_prompt(self, operation: GenerationOperation) -> str: + return load_prompt(operation) + + def generate( + self, + *, + operation: GenerationOperation, + payload: Mapping[str, object], + response_model: type[DraftT], + ) -> DraftT: + json_schema = response_model.model_json_schema(mode="validation") + required_fields = json_schema.get("required", []) + required_instruction = json.dumps(required_fields, ensure_ascii=False) + system_prompt = ( + f"{self.get_system_prompt(operation)}\n" + f"Required JSON keys: {required_instruction}. Return every required key." + ) + safe_payload = _sanitize_payload(payload) + + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + request_body = { + "model": self.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": json.dumps(safe_payload, ensure_ascii=False)}, + ], + "stream": False, + "format": json_schema, + "options": {"temperature": 0}, + } + url = f"{self.base_url}/api/chat" + last_error: Exception | None = None + + for attempt in range(1, 3): + try: + with httpx.Client( + transport=self._transport, + timeout=httpx.Timeout(self.timeout_seconds), + ) as client: + response = client.post(url, headers=headers, json=request_body) + + if len(response.content) > 1_048_576: + raise GenerationResponseTooLargeError( + "Response content exceeds 1 MiB limit" + ) + + if response.status_code == 200: + try: + response_json = response.json() + content = response_json.get("message", {}).get("content") + if not isinstance(content, str): + raise GenerationSchemaError( + "Content is missing or not a string" + ) + except (json.JSONDecodeError, AttributeError) as err: + raise GenerationSchemaError( + f"Invalid Ollama response wrapper: {err}" + ) from err + + normalized_content = _strip_single_json_code_fence(content) + try: + return response_model.model_validate_json(normalized_content) + except Exception as err: + raise GenerationSchemaError( + f"Model validation error for {response_model.__name__}: {err}" + ) from err + + if response.status_code in (429, 500, 502, 503, 504): + last_error = GenerationTransportError( + f"HTTP transport status {response.status_code}" + ) + if attempt < 2: + continue + raise last_error + + raise GenerationHTTPError( + f"HTTP generation request failed with status {response.status_code}" + ) + except (httpx.TimeoutException, httpx.NetworkError, httpx.TransportError) as err: + last_error = GenerationTransportError( + f"Network transport error: {type(err).__name__}" + ) + if attempt < 2: + continue + raise last_error from None + + if last_error: + raise last_error + raise GenerationError("Unknown generation failure") + + +__all__ = ["OllamaGenerationPort"] diff --git a/app/api/dependencies.py b/app/api/dependencies.py index 536b7df..a3aab53 100644 --- a/app/api/dependencies.py +++ b/app/api/dependencies.py @@ -3,6 +3,7 @@ from functools import lru_cache from fastapi import HTTPException, Request, status +from pydantic import ValidationError from app.agents.ambiguity import AmbiguityAgent from app.agents.intent import IntentClassifier, build_intent_agent @@ -12,14 +13,19 @@ load_workflow_catalog, try_get_repository, ) +from app.agents.language.composition import ( + LanguageAssistantCompositionUnavailable, + build_language_assistant_service, +) +from app.agents.language.service import LanguageAssistantService from app.agents.pipeline import AnalysisPipeline from app.agents.workflow import WorkflowAgent from app.agents.workflow_graph import RenewalOrchestrator +from app.agents.workflow_graph.language_bridge import build_renewal_language_guide from app.agents.workflow_graph.nodes.document_generator import ( EditingServiceDocumentGenerator, ) from app.agents.workflow_graph.nodes.language_stub import StubLanguageNode -from app.agents.workflow_graph.language_bridge import build_renewal_language_guide from app.agents.workflow_graph.ocr_bridge import DocumentOcrNode from app.agents.workflow_graph.task_store import InMemoryTaskStore from app.core.config import get_settings @@ -197,10 +203,12 @@ def get_document_conversion_service() -> DocumentConversionService: return DocumentConversionService(tuple(converters)) -def get_language_assistant_service() -> "LanguageAssistantService": # type: ignore[name-defined] # noqa: F821 - from fastapi import HTTPException - - raise HTTPException(status_code=503, detail="LANGUAGE_ASSISTANT_NOT_CONFIGURED") +@lru_cache +def get_language_assistant_service() -> LanguageAssistantService: + try: + return build_language_assistant_service(get_settings()) + except (LanguageAssistantCompositionUnavailable, ValidationError) as exc: + raise HTTPException(status_code=503, detail="LANGUAGE_ASSISTANT_NOT_CONFIGURED") from exc def get_ocr_service(request: Request) -> OcrService: diff --git a/app/core/config.py b/app/core/config.py index 07d3091..438c775 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -34,8 +34,9 @@ class Settings(BaseSettings): / "fowoco-document-snapshots" ) - # LLM — 미설정 시 템플릿 기반 stub 동작 + # LLM — 미설정 시 Language Assistant composition unavailable llm_provider: str | None = None + llm_base_url: str | None = None llm_api_key: str | None = None llm_model: str | None = None diff --git a/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md b/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md new file mode 100644 index 0000000..c0497f4 --- /dev/null +++ b/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md @@ -0,0 +1,204 @@ +# Language Assistant Runtime Composition Evidence + +```yaml +evidence_version: 1 +task: issue-24-runtime-composition +branch: feat/language-assistant-runtime-composition +worktree: /Users/parktaejung/Desktop/workspace/ai/.worktrees/language-assistant-runtime-composition +base_sha: f7058c2ece93e2b3723a715780e6bac5adb3eae1 +implementation_commit: not committed +live_ollama_qdrant: partial +ollama_model: gemma4:26b-mlx +ollama_structured_output: success +qdrant_endpoint: http://localhost:6333 +``` + +## Claims + +| ID | Claim | Evidence | +|---|---|---| +| C01 | 설정이 유효하면 getter가 실제 `LanguageAssistantService`를 lazy하게 반환한다. | `test_factory_builds_service_from_valid_generation_settings`, `test_endpoint_uses_real_dependency_with_deterministic_ports` | +| C02 | getter의 무조건적인 503 sentinel을 제거하고, 구성 실패 시 HTTP 503을 유지한다. | `test_dependency_returns_service_from_composition_factory`, `test_dependency_returns_503_when_generation_settings_are_missing`, `test_endpoint_returns_503_from_real_dependency_when_settings_are_missing`, `test_endpoint_returns_503_from_real_dependency_when_settings_are_invalid` | +| C03 | 직접 API가 dependency override 없이 실제 getter를 통과하며, 외부 네트워크 없이 결정적 port로 200 응답을 만든다. | `test_endpoint_uses_real_dependency_with_deterministic_ports` | +| C04 | 잘못된 provider/base URL/model 설정은 composition unavailable로 분류된다. | `test_factory_rejects_invalid_generation_settings` | +| C05 | Ollama/Qdrant live 호출 없이도 offline baseline을 재현할 수 있고, secret 파일 변경이 없다. | test commands below; no `.env` or secret file is in the change set | +| C06 | `provider=ollama`은 native `/api/chat` adapter를 사용하며, 실제 모델의 코드펜스 JSON을 정규화해 typed output으로 검증한다. | `test_ollama_adapter_sends_native_schema_contract`, `test_ollama_adapter_parses_single_json_code_fence`, live Ollama/API result below | + +## Contract decision + +API의 기존 503 detail 계약을 유지한다. + +```text +503 LANGUAGE_ASSISTANT_NOT_CONFIGURED +``` + +내부 composition exception의 code는 `LANGUAGE_ASSISTANT_COMPOSITION_UNAVAILABLE`로 유지하되, FastAPI 경계에서는 기존 detail로 변환한다. + +## RED before implementation + +초기 sentinel 상태에서 다음 focused command를 실행했다. + +```bash +PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=target \ +/Users/parktaejung/Desktop/workspace/ai-language-assistant/.venv/bin/python \ +-m pytest -p no:cacheprovider -q \ +tests/agents/language/test_runtime_config.py \ +tests/integration/language/test_runtime_composition.py \ +tests/api/test_language_endpoint.py +``` + +- Exit code: `1` +- Result: composition module와 `llm_base_url`이 없어 5개 실패 +- Meaning: 구현 전 실패 경계가 설정·factory·dependency에 존재함을 확인 + +## Verification + +### Composition and API focused suite + +```bash +PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=target \ +/Users/parktaejung/Desktop/workspace/ai-language-assistant/.venv/bin/python \ +-m pytest -p no:cacheprovider -q \ +tests/integration/language/test_runtime_composition.py \ +tests/api/test_language_endpoint.py +``` + +- Exit code: `0` +- Result: `24 passed` + +### Relevant regression suite + +```bash +PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=target \ +/Users/parktaejung/Desktop/workspace/ai-language-assistant/.venv/bin/python \ +-m pytest -p no:cacheprovider -q \ +tests/agents/language/test_runtime_config.py \ +tests/integration/language/test_runtime_composition.py \ +tests/api/test_language_endpoint.py \ +tests/agents/test_workflow_bridges.py \ +tests/agents/language/test_graph.py \ +tests/api/test_workflows_endpoint.py +``` + +- Exit code: `0` +- Result: `563 passed, 1 skipped` + +### Repository regression excluding unrelated OCR smoke environment tests + +```bash +PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=target \ +/Users/parktaejung/Desktop/workspace/ai-language-assistant/.venv/bin/python \ +-m pytest -p no:cacheprovider -q \ +--ignore=tests/ocr/test_smoke_script.py +``` + +- Exit code: `0` +- Result: all collected tests passed + +### Static checks + +```bash +/Users/parktaejung/Desktop/workspace/ai-language-assistant/.venv/bin/ruff check \ +app/core/config.py \ +app/agents/language/composition.py \ +app/agents/language/generation/__init__.py \ +app/agents/language/generation/ollama.py \ +app/api/dependencies.py \ +tests/agents/language/test_runtime_config.py \ +tests/agents/language/test_ollama_generation_port.py \ +tests/integration/language/test_runtime_composition.py \ +tests/api/test_language_endpoint.py +git diff --check +``` + +- Exit code: `0` +- Result: Ruff passed and no diff whitespace errors + +## Live verification + +### Ollama availability + +```bash +ollama list +curl http://localhost:11434/api/tags +curl http://localhost:11434/v1/models +``` + +- `ollama list`: `gemma4:26b-mlx` present +- `/api/tags`: HTTP `200` +- `/v1/models`: HTTP `200`, `gemma4:26b-mlx` present + +### Direct structured generation before fix + +실제 `OpenAICompatibleGenerationPort`를 `http://localhost:11434/v1`와 `gemma4:26b-mlx`로 구성해 `EasyKoreanDraft`를 요청했다. + +- HTTP transport/model reachability: 성공 +- Typed response validation: 실패 +- 모델 응답 key: `easy_korean_text` +- 현재 `EasyKoreanDraft` 요구 key: `request_reason`, `requested_items`, `submission_method` +- 결과: `GenerationSchemaError` + +필드명을 system prompt에 직접 명시하고 동일한 `response_format.json_schema`를 보낸 재시험도 수행했다. + +- HTTP status: `200` +- JSON parse: 실패(`JSONDecodeError`) +- 결론: OpenAI-compatible `response_format` 경로에서 schema 준수성이 확인되지 않음 + +### Ollama native structured generation after fix + +`provider=ollama` composition이 native `/api/chat` adapter를 선택하도록 수정했다. adapter는 `format=`를 전달하고, schema의 required fields를 system prompt에 추가하며, 응답 전체를 감싼 단일 JSON 코드펜스를 제거한 뒤 Pydantic validation을 수행한다. + +- HTTP status: `200` +- 응답 key: `request_reason`, `requested_items`, `submission_method` — 기대 구조와 일치 +- 실제 typed result: `EasyKoreanDraft` validation 성공 +- 코드펜스 응답: adapter의 제한적 정규화 후 validation 성공 +- 잘못된 `easy_korean_text` schema: `GenerationSchemaError` 유지 + +### Actual Language Assistant API + +수정 후 실제 설정(`provider=ollama`, base URL, model)을 주입하고 dependency cache를 초기화한 뒤 `POST /internal/v1/language-assistant`를 다시 호출했다. + +- HTTP status: `200` +- `generation_status`: `warning` (`failed` 해소) +- component status: standard Korean `success`, easy Korean `warning`, translation `success` +- `requires_human_review`: `true` +- warning codes: `EASY_KOREAN_CONTEXT_PACK_UNAVAILABLE`, `STANDARD_KOREAN_FALLBACK`, `RETRIEVAL_UNAVAILABLE`, `TRANSLATION_FALLBACK_USED` +- `TRANSLATION_GENERATION_FAILED`는 더 이상 발생하지 않음 +- 의미: 실제 dependency/API/Ollama structured generation 경로는 성공했고, 남은 warning은 context pack과 retrieval degraded 경로에서 발생함 + +### Qdrant availability + +```bash +curl http://localhost:6333/readyz +curl http://localhost:6333/collections +``` + +- 최초 host `localhost:6333` 확인: connection failed (`HTTP_STATUS=000`) +- 원인: 당시 OrbStack/Docker daemon이 실행되지 않았고 Compose는 host port를 공개하지 않음 +- OrbStack 시작 후 `qdrant/qdrant:v1.18.3` 컨테이너 기동: Qdrant HTTP server listening 확인 +- 컨테이너 IP `http://192.168.97.2:6333/readyz`: HTTP `200`, `all shards are ready` +- 컨테이너 IP `/collections`: HTTP `200`, collections `[]` +- Compose health: `unhealthy`; 이미지에 `wget`이 없어 현재 healthcheck가 실행되지 않음 +- `eps_language_phrases` collection/index contract: 아직 없음/확인하지 못함 + +## Not yet verified + +- Qdrant live request or EPS index contract verification +- Compose Qdrant healthcheck correction and `healthy` transition +- BGE-M3/reranker model loading or download + +현재 production composition은 Qdrant/BGE concrete backend가 없는 환경에서 retrieval을 typed degraded fallback으로 조립한다. 이 Evidence는 service/dependency/API composition과 실제 Ollama structured generation 성공 및 Qdrant server 도달성을 검증하며, live RAG 성공은 주장하지 않는다. + +## Known unrelated environment failures + +전체 suite를 `tests/ocr/test_smoke_script.py`까지 포함해 실행하면 기존 OCR smoke 테스트 2건이 macOS 환경 제약으로 실패한다. + +- PowerShell executable(`powershell`/`pwsh`) 미설치 +- sandbox에서 loopback HTTP server bind가 `PermissionError`로 차단 +- Qdrant Compose healthcheck가 존재하지 않는 `wget` 바이너리를 호출함 + +## Secret and scope audit + +- `.env`, API token, secret 값은 생성·수정·커밋하지 않았다. +- 변경 범위는 runtime composition, dependency, 설정, 관련 테스트, 이 Evidence 문서다. +- 기존 untracked implementation plan은 보존했으며 수정하지 않았다. diff --git a/tests/agents/language/test_ollama_generation_port.py b/tests/agents/language/test_ollama_generation_port.py new file mode 100644 index 0000000..0a38a6a --- /dev/null +++ b/tests/agents/language/test_ollama_generation_port.py @@ -0,0 +1,126 @@ +import json + +import httpx +import pytest + +from app.agents.language.generation.models import EasyKoreanDraft +from app.agents.language.generation.ollama import OllamaGenerationPort +from app.agents.language.generation.openai_compatible import GenerationSchemaError + + +def _valid_easy_korean_content() -> str: + return json.dumps( + { + "request_reason": "체류기간 연장 신청", + "requested_items": ["여권 사본"], + "submission_method": "출입국 관서 방문", + }, + ensure_ascii=False, + ) + + +def test_ollama_adapter_sends_native_schema_contract() -> None: + captured_requests: list[httpx.Request] = [] + + def handle_request(request: httpx.Request) -> httpx.Response: + captured_requests.append(request) + return httpx.Response( + 200, + json={ + "model": "gemma4:26b-mlx", + "message": { + "role": "assistant", + "content": _valid_easy_korean_content(), + }, + "done": True, + }, + ) + + port = OllamaGenerationPort( + base_url="http://localhost:11434/v1", + model="gemma4:26b-mlx", + transport=httpx.MockTransport(handle_request), + ) + + draft = port.generate( + operation="easy_korean", + payload={ + "request_reason": "체류기간 연장 신청", + "requested_items": ["여권 사본"], + "submission_method": "출입국 관서 방문", + }, + response_model=EasyKoreanDraft, + ) + + assert draft.request_reason == "체류기간 연장 신청" + assert len(captured_requests) == 1 + request = captured_requests[0] + assert str(request.url) == "http://localhost:11434/api/chat" + request_body = json.loads(request.content) + assert request_body["stream"] is False + assert request_body["format"]["required"] == [ + "request_reason", + "requested_items", + "submission_method", + ] + assert request_body["options"] == {"temperature": 0} + system_prompt = request_body["messages"][0]["content"] + assert "request_reason" in system_prompt + assert "requested_items" in system_prompt + assert "submission_method" in system_prompt + + +def test_ollama_adapter_parses_single_json_code_fence() -> None: + fenced_content = f"```json\n{_valid_easy_korean_content()}\n```" + + def handle_request(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "model": "gemma4:26b-mlx", + "message": {"role": "assistant", "content": fenced_content}, + "done": True, + }, + ) + + port = OllamaGenerationPort( + base_url="http://localhost:11434", + model="gemma4:26b-mlx", + transport=httpx.MockTransport(handle_request), + ) + + draft = port.generate( + operation="easy_korean", + payload={}, + response_model=EasyKoreanDraft, + ) + + assert draft.requested_items == ("여권 사본",) + + +def test_ollama_adapter_rejects_wrong_schema_inside_code_fence() -> None: + def handle_request(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "model": "gemma4:26b-mlx", + "message": { + "role": "assistant", + "content": '```json\n{"easy_korean_text": "신청하세요"}\n```', + }, + "done": True, + }, + ) + + port = OllamaGenerationPort( + base_url="http://localhost:11434", + model="gemma4:26b-mlx", + transport=httpx.MockTransport(handle_request), + ) + + with pytest.raises(GenerationSchemaError): + port.generate( + operation="easy_korean", + payload={}, + response_model=EasyKoreanDraft, + ) diff --git a/tests/agents/language/test_runtime_config.py b/tests/agents/language/test_runtime_config.py index 9b06e72..592b96e 100644 --- a/tests/agents/language/test_runtime_config.py +++ b/tests/agents/language/test_runtime_config.py @@ -88,6 +88,21 @@ def test_llm_timeout_rejects_negative(self, monkeypatch: pytest.MonkeyPatch) -> Settings() +class TestLlmBaseUrlConfig: + """FOWOCO_LLM_BASE_URL 환경변수 설정 검증.""" + + def test_llm_base_url_reads_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FOWOCO_LLM_BASE_URL", "http://ollama:11434/v1") + from app.core.config import get_settings + + get_settings.cache_clear() + try: + s = get_settings() + assert s.llm_base_url == "http://ollama:11434/v1" + finally: + get_settings.cache_clear() + + class TestModelCacheConfig: """FOWOCO_MODEL_CACHE_DIR 환경변수 설정 검증.""" diff --git a/tests/api/test_language_endpoint.py b/tests/api/test_language_endpoint.py index 9d0a6a9..0a7bbae 100644 --- a/tests/api/test_language_endpoint.py +++ b/tests/api/test_language_endpoint.py @@ -220,3 +220,213 @@ def test_endpoint_not_mounted_under_api_v1( assert resp.status_code == 200 openapi = resp.json() assert "/api/v1/internal/v1/language-assistant" not in openapi["paths"] + + +def test_dependency_returns_service_from_composition_factory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from app.api import dependencies + + expected_service = object() + monkeypatch.setattr( + dependencies, + "build_language_assistant_service", + lambda settings: expected_service, + raising=False, + ) + cache_clear = getattr(dependencies.get_language_assistant_service, "cache_clear", None) + if cache_clear is not None: + cache_clear() + try: + assert dependencies.get_language_assistant_service() is expected_service + finally: + if cache_clear is not None: + cache_clear() + + +def test_dependency_maps_composition_unavailable_to_503( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from fastapi import HTTPException + + from app.agents.language.composition import LanguageAssistantCompositionUnavailable + from app.api import dependencies + + def fail_to_build(settings: object) -> object: + raise LanguageAssistantCompositionUnavailable("missing language settings") + + monkeypatch.setattr(dependencies, "build_language_assistant_service", fail_to_build) + cache_clear = getattr(dependencies.get_language_assistant_service, "cache_clear", None) + if cache_clear is not None: + cache_clear() + try: + with pytest.raises(HTTPException) as exc_info: + dependencies.get_language_assistant_service() + assert exc_info.value.status_code == 503 + assert exc_info.value.detail == "LANGUAGE_ASSISTANT_NOT_CONFIGURED" + finally: + if cache_clear is not None: + cache_clear() + + +def test_dependency_returns_503_when_generation_settings_are_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from fastapi import HTTPException + + from app.api import dependencies + from app.core.config import get_settings + + for name in ( + "FOWOCO_LLM_PROVIDER", + "FOWOCO_LLM_BASE_URL", + "FOWOCO_LLM_MODEL", + ): + monkeypatch.delenv(name, raising=False) + get_settings.cache_clear() + dependencies.get_language_assistant_service.cache_clear() + try: + with pytest.raises(HTTPException) as exc_info: + dependencies.get_language_assistant_service() + assert exc_info.value.status_code == 503 + assert exc_info.value.detail == "LANGUAGE_ASSISTANT_NOT_CONFIGURED" + finally: + dependencies.get_language_assistant_service.cache_clear() + get_settings.cache_clear() + + +def test_endpoint_uses_real_dependency_with_deterministic_ports( + monkeypatch: pytest.MonkeyPatch, + app_instance: FastAPI, + request_payload: dict[str, object], +) -> None: + from app.agents.language.composition import ( + LanguageAssistantCompositionOverrides, + build_language_assistant_service, + ) + from app.agents.language.contracts import LanguageExecutionPolicy + from app.agents.language.generation.models import ( + EasyKoreanDraft, + TranslationDraft, + ) + from app.agents.language.ports import NoopTraceSink, SemanticValidationDecision + from app.agents.language.retrieval.models import RetrievalResult + from app.agents.language.service import LanguageAssistantService + from app.core.config import Settings + from tests.agents.language.fakes import ( + FakeEpsRetriever, + FakeSemanticValidationPort, + ) + + class DeterministicGenerator: + def generate( + self, + *, + operation: str, + payload: dict[str, object], + response_model: type[object], + ) -> object: + del operation, payload + if response_model is EasyKoreanDraft: + return EasyKoreanDraft( + request_reason="신청", + requested_items=("체류기간",), + submission_method="방문", + ) + if response_model is TranslationDraft: + return TranslationDraft( + translated_reason="Application", + translated_items=("stay period",), + translated_submission_method="In person", + ) + raise AssertionError(f"unexpected response model: {response_model}") + + overrides = LanguageAssistantCompositionOverrides( + generator=DeterministicGenerator(), + retriever=FakeEpsRetriever( + result=RetrievalResult( + dataset_version=None, + query_strategies=(), + contexts=(), + warnings=(), + fallback_used=True, + degraded_components=("retrieval",), + ) + ), + semantic_validator=FakeSemanticValidationPort( + result=SemanticValidationDecision(status="passed") + ), + trace_sink=NoopTraceSink(), + execution_policy=LanguageExecutionPolicy(), + ) + service = build_language_assistant_service(Settings(), overrides=overrides) + assert isinstance(service, LanguageAssistantService) + + from app.api import dependencies + + monkeypatch.setattr(dependencies, "build_language_assistant_service", lambda _: service) + dependencies.get_language_assistant_service.cache_clear() + app_instance.dependency_overrides.clear() + try: + response = TestClient(app_instance).post( + "/internal/v1/language-assistant", + json=request_payload, + ) + assert response.status_code == 200 + assert response.json()["worker_id"] == "worker-123" + assert "Reason: Application" in response.json()["translated_text"] + finally: + dependencies.get_language_assistant_service.cache_clear() + + +def test_endpoint_returns_503_from_real_dependency_when_settings_are_missing( + monkeypatch: pytest.MonkeyPatch, + app_instance: FastAPI, + request_payload: dict[str, object], +) -> None: + from app.api import dependencies + from app.core.config import get_settings + + for name in ( + "FOWOCO_LLM_PROVIDER", + "FOWOCO_LLM_BASE_URL", + "FOWOCO_LLM_MODEL", + ): + monkeypatch.delenv(name, raising=False) + get_settings.cache_clear() + dependencies.get_language_assistant_service.cache_clear() + app_instance.dependency_overrides.clear() + try: + response = TestClient(app_instance).post( + "/internal/v1/language-assistant", + json=request_payload, + ) + assert response.status_code == 503 + assert response.json()["detail"] == "LANGUAGE_ASSISTANT_NOT_CONFIGURED" + finally: + dependencies.get_language_assistant_service.cache_clear() + get_settings.cache_clear() + + +def test_endpoint_returns_503_from_real_dependency_when_settings_are_invalid( + monkeypatch: pytest.MonkeyPatch, + app_instance: FastAPI, + request_payload: dict[str, object], +) -> None: + from app.api import dependencies + from app.core.config import get_settings + + monkeypatch.setenv("FOWOCO_LLM_TIMEOUT_SECONDS", "0") + get_settings.cache_clear() + dependencies.get_language_assistant_service.cache_clear() + app_instance.dependency_overrides.clear() + try: + response = TestClient(app_instance).post( + "/internal/v1/language-assistant", + json=request_payload, + ) + assert response.status_code == 503 + assert response.json()["detail"] == "LANGUAGE_ASSISTANT_NOT_CONFIGURED" + finally: + dependencies.get_language_assistant_service.cache_clear() + get_settings.cache_clear() diff --git a/tests/integration/language/test_runtime_composition.py b/tests/integration/language/test_runtime_composition.py new file mode 100644 index 0000000..f035555 --- /dev/null +++ b/tests/integration/language/test_runtime_composition.py @@ -0,0 +1,128 @@ +import pytest + +from app.agents.language.contracts import LanguageExecutionPolicy +from app.agents.language.generation.models import EasyKoreanDraft +from app.agents.language.generation.ollama import OllamaGenerationPort +from app.agents.language.ports import NoopTraceSink, SemanticValidationDecision +from app.agents.language.retrieval.models import RetrievalResult +from app.agents.language.service import LanguageAssistantService +from app.core.config import Settings +from tests.agents.language.fakes import ( + FakeEpsRetriever, + FakeSemanticValidationPort, + FakeStructuredGenerationPort, +) + + +def _composition_types(): + try: + from app.agents.language.composition import ( + LanguageAssistantCompositionOverrides, + LanguageAssistantCompositionUnavailable, + build_language_assistant_service, + ) + except ModuleNotFoundError as exc: + pytest.fail(f"composition module missing: {exc}") + return ( + LanguageAssistantCompositionOverrides, + LanguageAssistantCompositionUnavailable, + build_language_assistant_service, + ) + + +def _test_overrides(): + overrides_type, _, _ = _composition_types() + return overrides_type( + generator=FakeStructuredGenerationPort( + result=EasyKoreanDraft( + request_reason="신청", + requested_items=("체류기간",), + submission_method="방문", + ) + ), + retriever=FakeEpsRetriever( + result=RetrievalResult( + dataset_version=None, + query_strategies=(), + contexts=(), + warnings=(), + fallback_used=True, + degraded_components=("retrieval",), + ) + ), + semantic_validator=FakeSemanticValidationPort( + result=SemanticValidationDecision(status="passed") + ), + trace_sink=NoopTraceSink(), + execution_policy=LanguageExecutionPolicy(), + ) + + +def test_factory_builds_service_from_explicit_test_ports() -> None: + _, _, build_service = _composition_types() + + service = build_service(Settings(), overrides=_test_overrides()) + + assert isinstance(service, LanguageAssistantService) + + +def test_factory_builds_service_from_valid_generation_settings() -> None: + _, _, build_service = _composition_types() + + service = build_service( + Settings( + llm_provider="openai-compatible", + llm_base_url="http://example.test/v1", + llm_model="test-model", + ) + ) + + assert isinstance(service, LanguageAssistantService) + + +def test_factory_selects_native_adapter_for_ollama_provider() -> None: + from app.agents.language.composition import _build_production_ports + + generator, _, _, _, _ = _build_production_ports( + Settings( + llm_provider="ollama", + llm_base_url="http://localhost:11434/v1", + llm_model="gemma4:26b-mlx", + ) + ) + + assert isinstance(generator, OllamaGenerationPort) + + +def test_factory_rejects_missing_generation_settings() -> None: + _, unavailable, build_service = _composition_types() + + with pytest.raises(unavailable): + build_service(Settings()) + + +@pytest.mark.parametrize( + "settings", + ( + Settings( + llm_provider="unsupported", + llm_base_url="http://example.test/v1", + llm_model="test-model", + ), + Settings( + llm_provider="openai-compatible", + llm_base_url="not-a-url", + llm_model="test-model", + ), + Settings( + llm_provider="openai-compatible", + llm_base_url="http://example.test/v1", + llm_model="", + ), + ), +) +def test_factory_rejects_invalid_generation_settings(settings: Settings) -> None: + _, unavailable, build_service = _composition_types() + + with pytest.raises(unavailable): + build_service(settings) From 7c566540fdd10294a3f1ff4261289aedad9d1199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A0=95?= Date: Mon, 10 Aug 2026 17:23:22 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat(language):=20Qdrant=EC=99=80=20BGE-M?= =?UTF-8?q?3=20=EA=B2=80=EC=83=89=20=EA=B2=BD=EB=A1=9C=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 4 +- app/agents/language/README.md | 2 +- app/agents/language/composition.py | 48 +- app/agents/language/generation/ollama.py | 1 + app/agents/language/retrieval/encoder.py | 57 +- app/agents/language/retrieval/indexer.py | 67 +- app/agents/language/retrieval/manifest.py | 31 + app/agents/language/retrieval/qdrant_store.py | 151 +- compose.test.yml | 9 +- compose.yml | 9 +- .../T13-RUNTIME-COMPOSITION-EVIDENCE.md | 130 +- ...e-assistant-qdrant-production-retrieval.md | 297 ++++ pyproject.toml | 5 + scripts/download_language_models.py | 8 +- scripts/index_eps_language.py | 71 +- tests/agents/language/test_indexer.py | 64 +- .../language/test_ollama_generation_port.py | 33 + .../agents/language/test_retrieval_service.py | 40 +- .../language/test_compose_config.py | 16 + .../language/test_qdrant_retrieval.py | 77 +- .../language/test_runtime_composition.py | 16 + uv.lock | 1440 ++++++++++++++++- 22 files changed, 2471 insertions(+), 105 deletions(-) create mode 100644 app/agents/language/retrieval/manifest.py create mode 100644 docs/language-assistant/engineering/plans/2026-08-10-language-assistant-qdrant-production-retrieval.md diff --git a/Dockerfile b/Dockerfile index 6d224dd..5c89ab9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,8 +44,8 @@ RUN apt-get update \ # 의존성 정의 파일만 먼저 복사해 캐시를 활용 COPY pyproject.toml uv.lock README.md ./ -# uv.lock 기반 재현 가능 설치 — 프로덕션 의존성만 -RUN uv sync --frozen --no-dev +# uv.lock 기반 재현 가능 설치 — Language Assistant retrieval 포함 +RUN uv sync --frozen --no-dev --extra language-retrieval # 앱 패키지 복사 COPY app ./app diff --git a/app/agents/language/README.md b/app/agents/language/README.md index d58b64d..63deab0 100644 --- a/app/agents/language/README.md +++ b/app/agents/language/README.md @@ -157,7 +157,7 @@ inp = LanguageAssistantInput( ```env # Vector DB (Qdrant) 연동 FOWOCO_QDRANT_URL=http://qdrant:6333 -FOWOCO_QDRANT_COLLECTION_ALIAS=eps_language_phrases_active +# Runtime collection alias는 eps_language_phrases_active로 고정 # LLM 연동 (OpenAI-compatible 규격) FOWOCO_LLM_PROVIDER=openai-compatible diff --git a/app/agents/language/composition.py b/app/agents/language/composition.py index c2f77ad..7d47acf 100644 --- a/app/agents/language/composition.py +++ b/app/agents/language/composition.py @@ -25,7 +25,18 @@ TraceSink, ) from app.agents.language.queries import SearchQuery +from app.agents.language.retrieval.encoder import ( + BgeM3Encoder, + FlagEmbeddingBgeM3Backend, +) +from app.agents.language.retrieval.manifest import ( + BGE_M3_REVISION, + QDRANT_COLLECTION_ALIAS, + build_expected_index_contract, +) from app.agents.language.retrieval.models import RetrievalResult +from app.agents.language.retrieval.qdrant_store import QdrantStore +from app.agents.language.retrieval.service import HybridEpsRetriever from app.agents.language.service import LanguageAssistantService from app.agents.language.validation import GeneratedSemanticValidator from app.core.config import Settings @@ -78,6 +89,36 @@ def retrieve( ) +def _build_retriever(settings: Settings) -> EpsRetriever: + if not settings.qdrant_url: + return _UnavailableRetriever("Qdrant is not configured") + try: + from qdrant_client import QdrantClient + + client = QdrantClient( + url=settings.qdrant_url, + api_key=settings.qdrant_api_key, + check_compatibility=False, + ) + except (ImportError, ValueError): + return _UnavailableRetriever("Qdrant client is unavailable") + + model_path = settings.model_cache_dir / "bge-m3" / BGE_M3_REVISION + encoder = BgeM3Encoder( + backend=FlagEmbeddingBgeM3Backend(str(model_path)), + ) + store = QdrantStore( + client=client, + collection_alias=QDRANT_COLLECTION_ALIAS, + ) + return HybridEpsRetriever( + encoder=encoder, + store=store, + reranker=None, + expected_index_contract=build_expected_index_contract(), + ) + + def _required_generation_settings(settings: Settings) -> tuple[str, str, str]: provider = (settings.llm_provider or "").strip().lower() base_url = (settings.llm_base_url or "").strip() @@ -114,12 +155,7 @@ def _build_production_ports( model=model, timeout_seconds=settings.llm_timeout_seconds, ) - retrieval_message = ( - "Qdrant is not configured" - if not settings.qdrant_url - else "Qdrant/BGE retrieval adapter is unavailable" - ) - retriever = _UnavailableRetriever(retrieval_message) + retriever = _build_retriever(settings) return ( generator, retriever, diff --git a/app/agents/language/generation/ollama.py b/app/agents/language/generation/ollama.py index 7dadc0d..edc2cfe 100644 --- a/app/agents/language/generation/ollama.py +++ b/app/agents/language/generation/ollama.py @@ -75,6 +75,7 @@ def generate( {"role": "user", "content": json.dumps(safe_payload, ensure_ascii=False)}, ], "stream": False, + "think": False, "format": json_schema, "options": {"temperature": 0}, } diff --git a/app/agents/language/retrieval/encoder.py b/app/agents/language/retrieval/encoder.py index 3aae19c..866f9b4 100644 --- a/app/agents/language/retrieval/encoder.py +++ b/app/agents/language/retrieval/encoder.py @@ -1,6 +1,6 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Protocol +from typing import Any, Protocol from app.agents.language.ports import DenseSparseEncoder from app.agents.language.retrieval.models import HybridVector @@ -26,6 +26,61 @@ def encode_queries( ) -> RawBgeBatch: ... +class FlagEmbeddingBgeM3Backend(BGEM3Backend): + def __init__(self, model_path: str, *, use_fp16: bool = True) -> None: + self.model_path = model_path + self.use_fp16 = use_fp16 + self._model: Any = None + + def _get_model(self) -> Any: + if self._model is None: + try: + from FlagEmbedding import BGEM3FlagModel + except ImportError as err: + raise RuntimeError("FlagEmbedding BGE-M3 model is not available") from err + self._model = BGEM3FlagModel( + self.model_path, + use_fp16=self.use_fp16, + ) + return self._model + + def token_count(self, text: str) -> int: + model = self._get_model() + return len(model.tokenizer.encode(text, add_special_tokens=True)) + + def encode_queries( + self, + texts: Sequence[str], + *, + max_length: int = 128, + return_dense: bool = True, + return_sparse: bool = True, + return_colbert_vecs: bool = False, + ) -> RawBgeBatch: + output = self._get_model().encode( + texts, + max_length=max_length, + return_dense=return_dense, + return_sparse=return_sparse, + return_colbert_vecs=return_colbert_vecs, + ) + try: + dense_vectors = tuple( + tuple(float(value) for value in vector) + for vector in output["dense_vecs"] + ) + lexical_weights = tuple( + {int(token_id): float(weight) for token_id, weight in weights.items()} + for weights in output["lexical_weights"] + ) + except (KeyError, TypeError, ValueError) as err: + raise RuntimeError("Invalid FlagEmbedding BGE-M3 output") from err + return RawBgeBatch( + dense_vectors=dense_vectors, + lexical_weights=lexical_weights, + ) + + class BgeM3Encoder(DenseSparseEncoder): def __init__( self, backend: BGEM3Backend, max_length: int = 128 diff --git a/app/agents/language/retrieval/indexer.py b/app/agents/language/retrieval/indexer.py index c12d5cc..4c2c6d0 100644 --- a/app/agents/language/retrieval/indexer.py +++ b/app/agents/language/retrieval/indexer.py @@ -8,7 +8,8 @@ from app.agents.language.codes import _LANGUAGE_ROWS from app.agents.language.contracts import EpsLanguageCode, SupportedLanguage -from app.agents.language.ports import EpsIndexStore +from app.agents.language.ports import DenseSparseEncoder, EpsIndexStore +from app.agents.language.retrieval.manifest import QDRANT_COLLECTION_ALIAS from app.agents.language.retrieval.models import ExpectedIndexContract EPS_UUID_NAMESPACE = uuid.UUID("9a528e10-4f51-4d37-9759-38b71d607f2c") @@ -144,7 +145,7 @@ def build_index_plan( records: Sequence[dict[str, Any]], expected_contract: ExpectedIndexContract, switch_alias: bool = False, - alias_name: str = "eps_language_phrases", + alias_name: str = QDRANT_COLLECTION_ALIAS, spec: CollectionSpec | None = None, ) -> None: if spec is None: @@ -188,3 +189,65 @@ def build_index_plan( if switch_alias: store.swap_alias(alias_name, collection_name) + + +def build_embedded_index_plan( + *, + store: EpsIndexStore, + encoder: DenseSparseEncoder, + collection_name: str, + records: Sequence[dict[str, Any]], + expected_contract: ExpectedIndexContract, + batch_size: int = 100, + switch_alias: bool = False, + alias_name: str = QDRANT_COLLECTION_ALIAS, + spec: CollectionSpec | None = None, +) -> None: + if batch_size <= 0: + raise ValueError("batch_size must be positive") + if spec is None: + spec = CollectionSpec() + + store.create_collection(collection_name, spec) + store.ensure_payload_indexes( + collection_name, + ( + "eps_language_code", + "target_language", + "quality_status", + "dataset_revision", + "embedding_model_repo", + "embedding_model_revision", + "index_contract_version", + ), + ) + + for start in range(0, len(records), batch_size): + batch = records[start : start + batch_size] + vectors = encoder.encode_queries([str(record["korean_text"]) for record in batch]) + points: list[dict[str, Any]] = [] + for record, vector in zip(batch, vectors, strict=True): + payload = dict(record) + payload["dense"] = list(vector.dense) + payload["sparse_indices"] = list(vector.sparse_indices) + payload["sparse_values"] = list(vector.sparse_values) + points.append({"id": record["point_id"], "payload": payload}) + store.upsert_batch(collection_name, tuple(points)) + + expected_count = ( + expected_contract.point_count + if expected_contract.point_count is not None + else len(records) + ) + expected_languages = tuple( + sorted({str(record["target_language"]) for record in records}) + ) + store.verify_collection( + collection_name, + expected_count=expected_count, + spec=spec, + expected_languages=expected_languages, + expected_contract=expected_contract, + ) + if switch_alias: + store.swap_alias(alias_name, collection_name) diff --git a/app/agents/language/retrieval/manifest.py b/app/agents/language/retrieval/manifest.py new file mode 100644 index 0000000..976bbb8 --- /dev/null +++ b/app/agents/language/retrieval/manifest.py @@ -0,0 +1,31 @@ +from app.agents.language.retrieval.models import ExpectedIndexContract + +QDRANT_COLLECTION_ALIAS = "eps_language_phrases_active" +EPS_DATASET_REVISION = ( + "sha256:29106c33d43ccdd8453623ac1a0af44e0201d7c7cc1cc68c3fb438e0ccc61c6d" +) +BGE_M3_MODEL_REPO = "BAAI/bge-m3" +BGE_M3_REVISION = "5617a9f61b028005a4858fdac845db406aefb181" +INDEX_CONTRACT_VERSION = "eps-language-index-v1" +EPS_POINT_COUNT = 17_902 + + +def build_expected_index_contract() -> ExpectedIndexContract: + return ExpectedIndexContract( + dataset_revision=EPS_DATASET_REVISION, + embedding_model_repo=BGE_M3_MODEL_REPO, + embedding_model_revision=BGE_M3_REVISION, + index_contract_version=INDEX_CONTRACT_VERSION, + point_count=EPS_POINT_COUNT, + ) + + +__all__ = [ + "BGE_M3_MODEL_REPO", + "BGE_M3_REVISION", + "EPS_DATASET_REVISION", + "EPS_POINT_COUNT", + "INDEX_CONTRACT_VERSION", + "QDRANT_COLLECTION_ALIAS", + "build_expected_index_contract", +] diff --git a/app/agents/language/retrieval/qdrant_store.py b/app/agents/language/retrieval/qdrant_store.py index 138380c..a37fe72 100644 --- a/app/agents/language/retrieval/qdrant_store.py +++ b/app/agents/language/retrieval/qdrant_store.py @@ -22,7 +22,7 @@ class _MockQModels: class Distance: COSINE = "COSINE" - class PayloadSchema: + class PayloadSchemaType: KEYWORD = "keyword" class Fusion: @@ -64,13 +64,40 @@ def CreateAliasOperation(self, **kwargs: Any) -> Any: def CreateAlias(self, **kwargs: Any) -> Any: return kwargs + def DeleteAliasOperation(self, **kwargs: Any) -> Any: + return kwargs + + def DeleteAlias(self, **kwargs: Any) -> Any: + return kwargs + QdrantClient = Any qmodels = _MockQModels() class QdrantStore(HybridSearchStore, EpsIndexStore): - def __init__(self, client: Any) -> None: + def __init__( + self, + client: Any, + collection_alias: str = "eps_language_phrases_active", + ) -> None: self.client = client + self.collection_alias = collection_alias + + @staticmethod + def _verify_vector_schema(info: Any, *, expected_dense_size: int = 1024) -> None: + vectors = info.config.params.vectors + if not (isinstance(vectors, dict) and "korean_dense" in vectors): + raise ValueError("RETRIEVAL_SCHEMA_MISMATCH") + dense = vectors["korean_dense"] + distance = str(getattr(dense, "distance", "")).upper() + if dense.size != expected_dense_size or "COSINE" not in distance: + raise ValueError("RETRIEVAL_SCHEMA_MISMATCH") + + sparse_vectors = info.config.params.sparse_vectors + if not ( + isinstance(sparse_vectors, dict) and "korean_sparse" in sparse_vectors + ): + raise ValueError("RETRIEVAL_SCHEMA_MISMATCH") def create_collection( self, collection_name: str, spec: CollectionSpec | None = None @@ -101,7 +128,7 @@ def ensure_payload_indexes( self.client.create_payload_index( collection_name=collection_name, field_name=field, - field_schema=qmodels.PayloadSchema.KEYWORD, + field_schema=qmodels.PayloadSchemaType.KEYWORD, ) def upsert_batch( @@ -135,13 +162,13 @@ def verify_contract( aliases = self.client.get_aliases() target_coll = None for a in aliases.aliases: - if a.alias_name == "eps_language_phrases": + if a.alias_name == self.collection_alias: target_coll = a.collection_name break if target_coll is None: - if self.client.collection_exists("eps_language_phrases"): - target_coll = "eps_language_phrases" + if self.client.collection_exists(self.collection_alias): + target_coll = self.collection_alias else: raise ValueError("RETRIEVAL_UNAVAILABLE") @@ -151,26 +178,13 @@ def verify_contract( except Exception as err: raise ValueError("RETRIEVAL_UNAVAILABLE") from err - # Verify vectors schema - vconfig = info.config.params.vectors - if isinstance(vconfig, dict) and "korean_dense" in vconfig: - dense_param = vconfig["korean_dense"] - dist_str = str(getattr(dense_param, "distance", "")).upper() - if ( - dense_param.size != 1024 - or ("COSINE" not in dist_str) - ): - raise ValueError("RETRIEVAL_SCHEMA_MISMATCH") - else: - raise ValueError("RETRIEVAL_SCHEMA_MISMATCH") - - sparse_config = info.config.params.sparse_vectors - if not (isinstance(sparse_config, dict) and "korean_sparse" in sparse_config): - raise ValueError("RETRIEVAL_SCHEMA_MISMATCH") + self._verify_vector_schema(info) point_count = info.points_count or 0 if point_count <= 0: raise ValueError("RETRIEVAL_UNAVAILABLE") + if expected.point_count is not None and point_count != expected.point_count: + raise ValueError("RETRIEVAL_UNAVAILABLE") # Exact provenance filters verification ds_count = self.client.count( @@ -224,6 +238,76 @@ def verify_contract( point_count=point_count, ) + def verify_collection( + self, + collection_name: str, + expected_count: int, + spec: CollectionSpec, + expected_languages: tuple[str, ...], + expected_contract: ExpectedIndexContract, + ) -> None: + try: + info = self.client.get_collection(collection_name=collection_name) + except Exception as err: + raise ValueError("RETRIEVAL_UNAVAILABLE") from err + + point_count = info.points_count or 0 + if point_count != expected_count: + raise ValueError("RETRIEVAL_UNAVAILABLE") + self._verify_vector_schema( + info, + expected_dense_size=spec.dense_vector_size, + ) + + for language in expected_languages: + language_count = self.client.count( + collection_name=collection_name, + count_filter=qmodels.Filter( + must=[ + qmodels.FieldCondition( + key="target_language", + match=qmodels.MatchValue(value=language), + ) + ] + ), + ).count + if language_count <= 0: + raise ValueError("RETRIEVAL_DATASET_MISMATCH") + + provenance_count = self.client.count( + collection_name=collection_name, + count_filter=qmodels.Filter( + must=[ + qmodels.FieldCondition( + key="dataset_revision", + match=qmodels.MatchValue( + value=expected_contract.dataset_revision + ), + ), + qmodels.FieldCondition( + key="embedding_model_repo", + match=qmodels.MatchValue( + value=expected_contract.embedding_model_repo + ), + ), + qmodels.FieldCondition( + key="embedding_model_revision", + match=qmodels.MatchValue( + value=expected_contract.embedding_model_revision + ), + ), + qmodels.FieldCondition( + key="index_contract_version", + match=qmodels.MatchValue( + value=expected_contract.index_contract_version + ), + ), + ] + ), + ).count + if provenance_count != expected_count: + raise ValueError("RETRIEVAL_INDEX_PROVENANCE_MISMATCH") + def search_many( self, queries: Sequence[tuple[SearchQuery, HybridVector]], @@ -322,12 +406,21 @@ def search_many( return tuple(rankings) def swap_alias(self, alias_name: str, collection_name: str) -> None: - self.client.update_collection_aliases( - change_aliases=[ - qmodels.CreateAliasOperation( - create_alias=qmodels.CreateAlias( - collection_name=collection_name, alias_name=alias_name - ) + aliases = self.client.get_aliases().aliases + changes = [] + if any(alias.alias_name == alias_name for alias in aliases): + changes.append( + qmodels.DeleteAliasOperation( + delete_alias=qmodels.DeleteAlias(alias_name=alias_name) ) - ] + ) + changes.append( + qmodels.CreateAliasOperation( + create_alias=qmodels.CreateAlias( + collection_name=collection_name, alias_name=alias_name + ) + ) + ) + self.client.update_collection_aliases( + change_aliases_operations=changes ) diff --git a/compose.test.yml b/compose.test.yml index 65c253d..9681b0f 100644 --- a/compose.test.yml +++ b/compose.test.yml @@ -13,7 +13,14 @@ services: volumes: - fowoco-qdrant-test-data:/qdrant/storage healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:6333/readyz"] + test: + - CMD + - /bin/bash + - -ec + - >- + exec 3<>/dev/tcp/127.0.0.1/6333; + printf 'GET /readyz HTTP/1.0\r\nHost: localhost\r\n\r\n' >&3; + grep -q 'all shards are ready' <&3 interval: 5s timeout: 5s retries: 10 diff --git a/compose.yml b/compose.yml index 8d93791..f7754d4 100644 --- a/compose.yml +++ b/compose.yml @@ -12,7 +12,14 @@ services: volumes: - fowoco-qdrant-data:/qdrant/storage healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:6333/readyz"] + test: + - CMD + - /bin/bash + - -ec + - >- + exec 3<>/dev/tcp/127.0.0.1/6333; + printf 'GET /readyz HTTP/1.0\r\nHost: localhost\r\n\r\n' >&3; + grep -q 'all shards are ready' <&3 interval: 10s timeout: 5s retries: 10 diff --git a/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md b/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md index c0497f4..69ac8a1 100644 --- a/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md +++ b/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md @@ -6,11 +6,12 @@ task: issue-24-runtime-composition branch: feat/language-assistant-runtime-composition worktree: /Users/parktaejung/Desktop/workspace/ai/.worktrees/language-assistant-runtime-composition base_sha: f7058c2ece93e2b3723a715780e6bac5adb3eae1 -implementation_commit: not committed -live_ollama_qdrant: partial +implementation_commit: 91e08af (runtime/Ollama); this commit (Qdrant/BGE/Docker) +live_ollama_qdrant: success-with-easy-korean-fallback ollama_model: gemma4:26b-mlx ollama_structured_output: success -qdrant_endpoint: http://localhost:6333 +qdrant_endpoint: http://localhost:16333 (isolated compose.test.yml) +qdrant_retrieval: success ``` ## Claims @@ -22,7 +23,10 @@ qdrant_endpoint: http://localhost:6333 | C03 | 직접 API가 dependency override 없이 실제 getter를 통과하며, 외부 네트워크 없이 결정적 port로 200 응답을 만든다. | `test_endpoint_uses_real_dependency_with_deterministic_ports` | | C04 | 잘못된 provider/base URL/model 설정은 composition unavailable로 분류된다. | `test_factory_rejects_invalid_generation_settings` | | C05 | Ollama/Qdrant live 호출 없이도 offline baseline을 재현할 수 있고, secret 파일 변경이 없다. | test commands below; no `.env` or secret file is in the change set | -| C06 | `provider=ollama`은 native `/api/chat` adapter를 사용하며, 실제 모델의 코드펜스 JSON을 정규화해 typed output으로 검증한다. | `test_ollama_adapter_sends_native_schema_contract`, `test_ollama_adapter_parses_single_json_code_fence`, live Ollama/API result below | +| C06 | `provider=ollama`은 native `/api/chat` adapter를 사용하고 thinking을 끄며, 실제 모델의 코드펜스 JSON을 정규화해 typed output으로 검증한다. | `test_ollama_adapter_sends_native_schema_contract`, `test_ollama_adapter_disables_thinking_for_structured_generation`, `test_ollama_adapter_parses_single_json_code_fence`, live Ollama/API result below | +| C07 | 유효한 Qdrant 설정은 production `HybridEpsRetriever`를 조립하고, 고정 index contract를 통과한 collection만 검색한다. | `test_factory_selects_hybrid_retriever_when_qdrant_is_configured`, `test_real_store_mock_create_and_verify`, live retrieval result below | +| C08 | 실제 BGE-M3/Qdrant 검색은 5개 reference를 반환하며 retrieval fallback/warning이 없다. | isolated Qdrant/BGE live result below | +| C09 | production Docker image는 `language-retrieval` extra를 설치하고 앱과 retrieval 의존성을 import할 수 있다. | `docker compose build ai` exit `0`; image import smoke result below | ## Contract decision @@ -81,38 +85,46 @@ tests/api/test_workflows_endpoint.py ``` - Exit code: `0` -- Result: `563 passed, 1 skipped` +- Result: all collected tests passed ### Repository regression excluding unrelated OCR smoke environment tests ```bash -PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=target \ -/Users/parktaejung/Desktop/workspace/ai-language-assistant/.venv/bin/python \ --m pytest -p no:cacheprovider -q \ ---ignore=tests/ocr/test_smoke_script.py +PYTHONPATH=. /opt/homebrew/bin/uv run --frozen \ + --extra dev --extra language-retrieval \ + pytest --ignore=tests/ocr/test_smoke_script.py ``` - Exit code: `0` -- Result: all collected tests passed +- Result: `573 passed, 1 skipped` ### Static checks ```bash -/Users/parktaejung/Desktop/workspace/ai-language-assistant/.venv/bin/ruff check \ -app/core/config.py \ +/opt/homebrew/bin/uv run --frozen --extra dev --extra language-retrieval \ +ruff check \ app/agents/language/composition.py \ -app/agents/language/generation/__init__.py \ app/agents/language/generation/ollama.py \ -app/api/dependencies.py \ -tests/agents/language/test_runtime_config.py \ +app/agents/language/retrieval/encoder.py \ +app/agents/language/retrieval/indexer.py \ +app/agents/language/retrieval/manifest.py \ +app/agents/language/retrieval/qdrant_store.py \ +scripts/download_language_models.py \ +scripts/index_eps_language.py \ +tests/agents/language/test_indexer.py \ tests/agents/language/test_ollama_generation_port.py \ -tests/integration/language/test_runtime_composition.py \ -tests/api/test_language_endpoint.py +tests/agents/language/test_retrieval_service.py \ +tests/integration/language/test_compose_config.py \ +tests/integration/language/test_qdrant_retrieval.py \ +tests/integration/language/test_runtime_composition.py +/opt/homebrew/bin/uv lock --check +docker compose config --quiet +docker compose -f compose.test.yml config --quiet git diff --check ``` - Exit code: `0` -- Result: Ruff passed and no diff whitespace errors +- Result: Ruff, lockfile, production/test Compose, diff whitespace checks passed ## Live verification @@ -154,7 +166,7 @@ curl http://localhost:11434/v1/models - 코드펜스 응답: adapter의 제한적 정규화 후 validation 성공 - 잘못된 `easy_korean_text` schema: `GenerationSchemaError` 유지 -### Actual Language Assistant API +### Actual Language Assistant API with Ollama only 수정 후 실제 설정(`provider=ollama`, base URL, model)을 주입하고 dependency cache를 초기화한 뒤 `POST /internal/v1/language-assistant`를 다시 호출했다. @@ -166,28 +178,73 @@ curl http://localhost:11434/v1/models - `TRANSLATION_GENERATION_FAILED`는 더 이상 발생하지 않음 - 의미: 실제 dependency/API/Ollama structured generation 경로는 성공했고, 남은 warning은 context pack과 retrieval degraded 경로에서 발생함 -### Qdrant availability +### Qdrant readiness after fix -```bash -curl http://localhost:6333/readyz -curl http://localhost:6333/collections -``` +기존 `wget` healthcheck는 Qdrant 이미지에 실행 파일이 없어 false `unhealthy`를 만들었다. 이미지에 실제 존재하는 `/bin/bash`와 `/dev/tcp`로 `/readyz`를 검사하도록 production/test Compose를 수정했다. + +- production `fowoco-qdrant`: `healthy` +- isolated `fowoco-qdrant-test`: `healthy` +- `http://localhost:16333/readyz`: HTTP `200`, `all shards are ready` +- production Compose의 host port 비공개 계약 유지 + +### Actual BGE-M3 indexing and Qdrant retrieval + +- model: `BAAI/bge-m3@5617a9f61b028005a4858fdac845db406aefb181` +- source: `data/eps_language_db.json` +- source rows: `17,925` +- usable/indexed points: `17,902` +- collection: `eps_language_phrases_29106c33d43c_5617a9f61b02` +- alias: `eps_language_phrases_active` +- dense vector: `korean_dense`, 1024 dimensions, cosine +- sparse vector: `korean_sparse` +- dataset/model/index provenance verification: 성공 +- full indexing CLI idempotent rerun: exit `0`, `17,902` points +- actual `HybridEpsRetriever`: contexts `5`, fallback `false`, warnings `[]` -- 최초 host `localhost:6333` 확인: connection failed (`HTTP_STATUS=000`) -- 원인: 당시 OrbStack/Docker daemon이 실행되지 않았고 Compose는 host port를 공개하지 않음 -- OrbStack 시작 후 `qdrant/qdrant:v1.18.3` 컨테이너 기동: Qdrant HTTP server listening 확인 -- 컨테이너 IP `http://192.168.97.2:6333/readyz`: HTTP `200`, `all shards are ready` -- 컨테이너 IP `/collections`: HTTP `200`, collections `[]` -- Compose health: `unhealthy`; 이미지에 `wget`이 없어 현재 healthcheck가 실행되지 않음 -- `eps_language_phrases` collection/index contract: 아직 없음/확인하지 못함 +live 인덱싱 중 qdrant-client 1.19 compatibility 오류 두 건을 재현하고 수정했다. + +- `PayloadSchema.KEYWORD` → `PayloadSchemaType.KEYWORD` +- `update_collection_aliases(change_aliases=...)` → `change_aliases_operations=...` + +### Actual Language Assistant API with Ollama and Qdrant + +- 최초 실패 재현: 동일 translation 요청이 기본 timeout `60`초에서 두 번 `ReadTimeout`되어 `120.64`초 후 typed failure로 변환됨 +- 원인: Qdrant payload/schema 오류가 아니라 로컬 `gemma4:26b-mlx` 응답 시간이 기본 timeout을 초과함 +- 동일 schema/payload를 `think=false`로 직접 호출: HTTP `200`, typed `TranslationDraft` 성공, `81.92`초와 `105.10`초 +- adapter 수정: native Ollama 요청에 `think: false` 명시 +- 실제 API 검증 설정: 테스트 프로세스에만 `FOWOCO_LLM_TIMEOUT_SECONDS=180` 주입; `.env`와 전역 Provider 설정은 변경하지 않음 +- HTTP status: `200` +- elapsed: `94.56`초 +- retrieval dataset version: `sha256:29106c33d43ccdd8453623ac1a0af44e0201d7c7cc1cc68c3fb438e0ccc61c6d` +- retrieval reference count: `5` +- retrieval fallback: `false` +- `RETRIEVAL_UNAVAILABLE`: 발생하지 않음 +- translation status: `success` +- translated text: 존재 +- overall `generation_status`: `warning` +- warning codes: `EASY_KOREAN_CONTEXT_PACK_UNAVAILABLE`, `STANDARD_KOREAN_FALLBACK` +- `TRANSLATION_GENERATION_FAILED`: 발생하지 않음 +- 의미: 실제 dependency → BGE-M3 → Qdrant → Ollama structured translation 경로가 끝까지 성공했다. 전체 warning은 승인된 Easy Korean Context Pack 부재에 따른 기존 fallback 계약이다. + +### Production Docker image build + +- command: `docker compose build ai` +- exit: `0` +- image: `fowoco-ai:latest` +- image id: `sha256:81de153f32fdcb7222af1281352ef4759c7c91a82c1f78c55c7f481e6a86b291` +- architecture: `arm64` +- image size: `3,506,302,676` bytes +- installed retrieval packages: `qdrant-client==1.19.0`, `FlagEmbedding==1.4.0`, `torch==2.13.0` +- production execution path smoke: `docker run --rm fowoco-ai:latest uv run python -c ...` → `FastAPI` +- 주의: Linux Torch가 CUDA 계열 wheel을 포함해 이미지가 3.51 GB다. 빌드는 성공했지만 이미지 경량화는 별도 최적화 대상이다. ## Not yet verified -- Qdrant live request or EPS index contract verification -- Compose Qdrant healthcheck correction and `healthy` transition -- BGE-M3/reranker model loading or download +- production Qdrant volume indexing; live data는 격리된 test volume에 생성함 +- BGE reranker 연결; 현재 production composition은 cross-query RRF fallback을 사용함 +- actual OpenAI API structured-output compatibility -현재 production composition은 Qdrant/BGE concrete backend가 없는 환경에서 retrieval을 typed degraded fallback으로 조립한다. 이 Evidence는 service/dependency/API composition과 실제 Ollama structured generation 성공 및 Qdrant server 도달성을 검증하며, live RAG 성공은 주장하지 않는다. +현재 production composition은 Qdrant URL이 없으면 typed degraded fallback을 사용하고, 유효한 URL에서는 lazy BGE-M3 backend와 `HybridEpsRetriever`를 조립한다. 실제 BGE-M3/Qdrant indexing·retrieval과 Ollama 결합 API 호출은 검증됐으며, reranker와 실제 OpenAI API 호환성은 주장하지 않는다. ## Known unrelated environment failures @@ -195,10 +252,9 @@ curl http://localhost:6333/collections - PowerShell executable(`powershell`/`pwsh`) 미설치 - sandbox에서 loopback HTTP server bind가 `PermissionError`로 차단 -- Qdrant Compose healthcheck가 존재하지 않는 `wget` 바이너리를 호출함 ## Secret and scope audit - `.env`, API token, secret 값은 생성·수정·커밋하지 않았다. -- 변경 범위는 runtime composition, dependency, 설정, 관련 테스트, 이 Evidence 문서다. +- 변경 범위는 runtime composition, Ollama adapter, Qdrant/BGE retrieval, indexing, Docker readiness, dependency, 관련 테스트와 Evidence 문서다. - 기존 untracked implementation plan은 보존했으며 수정하지 않았다. diff --git a/docs/language-assistant/engineering/plans/2026-08-10-language-assistant-qdrant-production-retrieval.md b/docs/language-assistant/engineering/plans/2026-08-10-language-assistant-qdrant-production-retrieval.md new file mode 100644 index 0000000..96188da --- /dev/null +++ b/docs/language-assistant/engineering/plans/2026-08-10-language-assistant-qdrant-production-retrieval.md @@ -0,0 +1,297 @@ +# Language Assistant Qdrant Production Retrieval Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 유효한 Qdrant·BGE-M3·EPS index 설정에서 Language Assistant가 `HybridEpsRetriever`를 사용하고, 장애나 contract 불일치 시 기존 typed degraded output을 유지한다. + +**Architecture:** Qdrant client와 BGE-M3 모델은 module import/service construction 시 네트워크 또는 모델 로드를 일으키지 않는다. 실제 request에서 index contract를 먼저 검증하고, 검증된 collection에만 dense+sparse query를 실행한다. 인덱싱은 고정된 dataset/model revision으로 새 collection을 만들고 검증 성공 후 `eps_language_phrases_active` alias를 원자적으로 전환한다. + +**Tech Stack:** Python 3.12, FastAPI, LangGraph, Qdrant 1.18.3, qdrant-client 1.x, FlagEmbedding BGE-M3, pytest, Docker Compose + +## Global Constraints + +- 작업 위치: `/Users/parktaejung/Desktop/workspace/ai/.worktrees/language-assistant-runtime-composition` +- 브랜치: `feat/language-assistant-runtime-composition` +- Qdrant production service는 호스트 포트를 공개하지 않는다. +- collection alias는 `eps_language_phrases_active`로 통일한다. +- dataset revision은 `sha256:29106c33d43ccdd8453623ac1a0af44e0201d7c7cc1cc68c3fb438e0ccc61c6d`로 고정한다. +- BGE-M3 revision은 `5617a9f61b028005a4858fdac845db406aefb181`로 고정한다. +- index contract version은 `eps-language-index-v1`을 유지한다. +- module import, `create_app()`, OpenAPI 생성은 Qdrant 호출이나 모델 로드를 하지 않는다. +- `.env`, token, secret은 생성·수정·커밋하지 않는다. +- 기존 untracked `2026-08-10-language-assistant-runtime-composition.md`는 수정하거나 스테이징하지 않는다. + +--- + +### Task 1: Docker Qdrant readiness 계약 + +**Files:** +- Modify: `compose.yml` +- Modify: `compose.test.yml` +- Test: `tests/integration/language/test_compose_config.py` + +**Interfaces:** +- Consumes: Qdrant image `qdrant/qdrant:v1.18.3` +- Produces: 컨테이너 내부 `/readyz`를 검사하는 실행 가능한 healthcheck + +- [ ] **Step 1: 사용할 수 없는 `wget`을 거부하는 실패 테스트 작성** + +```python +def test_qdrant_healthcheck_uses_available_bash_tcp_probe() -> None: + data = yaml.safe_load((ROOT / "compose.yml").read_text()) + command = data["services"]["qdrant"]["healthcheck"]["test"] + assert command[:2] == ["CMD", "/bin/bash"] + assert "/dev/tcp/127.0.0.1/6333" in command[-1] + assert "wget" not in " ".join(command) +``` + +- [ ] **Step 2: RED 확인** + +Run: `pytest -q tests/integration/language/test_compose_config.py` +Expected: 기존 healthcheck가 `wget`을 사용해 FAIL + +- [ ] **Step 3: production/test Compose healthcheck 교체** + +```yaml +healthcheck: + test: + - CMD + - /bin/bash + - -ec + - >- + exec 3<>/dev/tcp/127.0.0.1/6333; + printf 'GET /readyz HTTP/1.0\r\nHost: localhost\r\n\r\n' >&3; + grep -q 'all shards are ready' <&3 +``` + +- [ ] **Step 4: GREEN 및 실제 container health 확인** + +Run: `pytest -q tests/integration/language/test_compose_config.py` +Run: `docker compose up -d --force-recreate qdrant` +Run: `docker compose ps qdrant` +Expected: tests PASS, Qdrant `healthy` + +### Task 2: 고정 index manifest와 QdrantStore contract + +**Files:** +- Create: `app/agents/language/retrieval/manifest.py` +- Modify: `app/agents/language/retrieval/qdrant_store.py` +- Modify: `app/agents/language/retrieval/indexer.py` +- Test: `tests/integration/language/test_qdrant_retrieval.py` +- Test: `tests/agents/language/test_indexer.py` + +**Interfaces:** +- Produces: `build_expected_index_contract() -> ExpectedIndexContract` +- Produces: `QdrantStore(client, collection_alias="eps_language_phrases_active")` +- Produces: `QdrantStore.verify_collection(...) -> None` + +- [ ] **Step 1: alias와 실제 collection 검증 실패 테스트 작성** + +```python +def test_store_resolves_active_alias(expected_contract): + client = MagicMock() + client.get_aliases.return_value.aliases = [ + MagicMock(alias_name="eps_language_phrases_active", collection_name="versioned") + ] + # 1024 cosine, sparse vector, count/provenance fixtures + handle = QdrantStore(client).verify_contract(expected=expected_contract) + assert handle.collection_name == "versioned" +``` + +```python +def test_verify_collection_rejects_wrong_point_count(expected_contract): + client = MagicMock() + client.get_collection.return_value.points_count = 0 + with pytest.raises(ValueError, match="RETRIEVAL_UNAVAILABLE"): + QdrantStore(client).verify_collection( + "versioned", 100, CollectionSpec(), ("en",), expected_contract + ) +``` + +- [ ] **Step 2: RED 확인** + +Run: `pytest -q tests/integration/language/test_qdrant_retrieval.py tests/agents/language/test_indexer.py` +Expected: hard-coded old alias와 누락된 `verify_collection` 때문에 FAIL + +- [ ] **Step 3: manifest 및 store 검증 구현** + +```python +QDRANT_COLLECTION_ALIAS = "eps_language_phrases_active" +EPS_DATASET_REVISION = "sha256:29106c33d43ccdd8453623ac1a0af44e0201d7c7cc1cc68c3fb438e0ccc61c6d" +BGE_M3_REVISION = "5617a9f61b028005a4858fdac845db406aefb181" +INDEX_CONTRACT_VERSION = "eps-language-index-v1" +``` + +`verify_collection`은 vector schema, point count, target language, dataset/model/index provenance count가 모두 일치할 때만 성공한다. Alias 전환은 기존 alias 삭제와 새 alias 생성을 한 요청에 포함한다. + +- [ ] **Step 4: GREEN 확인** + +Run: `pytest -q tests/integration/language/test_qdrant_retrieval.py tests/agents/language/test_indexer.py` +Expected: PASS + +### Task 3: BGE-M3 production backend와 dependency + +**Files:** +- Modify: `pyproject.toml` +- Modify: `uv.lock` +- Modify: `app/agents/language/retrieval/encoder.py` +- Modify: `scripts/download_language_models.py` +- Test: `tests/agents/language/test_retrieval_service.py` +- Test: `tests/agents/language/test_model_cache.py` + +**Interfaces:** +- Produces: `FlagEmbeddingBgeM3Backend(model_path: str)` implementing `BGEM3Backend` +- Produces: `RawBgeBatch` with 1024-dimensional dense vectors and integer sparse token weights + +- [ ] **Step 1: lazy load 및 output 변환 실패 테스트 작성** + +```python +def test_flag_embedding_backend_converts_dense_and_lexical_weights(): + backend = FlagEmbeddingBgeM3Backend("/models/bge-m3") + backend._model = FakeFlagModel( + dense_vecs=[[0.1] * 1024], + lexical_weights=[{"1": 0.5, "9": 0.2}], + ) + result = backend.encode_queries(("고맙습니다",)) + assert len(result.dense_vectors[0]) == 1024 + assert result.lexical_weights[0] == {1: 0.5, 9: 0.2} +``` + +- [ ] **Step 2: RED 확인** + +Run: `pytest -q tests/agents/language/test_retrieval_service.py` +Expected: production backend가 없어 FAIL + +- [ ] **Step 3: lazy backend와 retrieval extra 구현** + +`FlagEmbeddingBgeM3Backend`는 첫 `token_count`/`encode_queries` 호출에서만 `BGEM3FlagModel`을 import/load한다. `pyproject.toml`의 `language-retrieval` extra에 `qdrant-client>=1.19,<2`, `FlagEmbedding>=1.3,<2`, `huggingface-hub>=0.36,<2`를 추가하고 lockfile을 갱신한다. + +- [ ] **Step 4: GREEN 확인** + +Run: `pytest -q tests/agents/language/test_retrieval_service.py tests/agents/language/test_model_cache.py` +Expected: PASS without model load/network + +### Task 4: 실제 EPS indexing pipeline + +**Files:** +- Modify: `app/agents/language/retrieval/indexer.py` +- Modify: `scripts/index_eps_language.py` +- Test: `tests/agents/language/test_indexer.py` + +**Interfaces:** +- Produces: `build_embedded_index_plan(store, encoder, collection_name, records, expected_contract, batch_size, alias_name)` +- Consumes: cleaned EPS records and `DenseSparseEncoder` + +- [ ] **Step 1: batch embedding/upsert 및 alias 안전성 실패 테스트 작성** + +```python +def test_embedded_index_plan_attaches_real_vectors_before_upsert(): + store = FakeEpsIndexStore() + encoder = FakeDenseSparseEncoder() + build_embedded_index_plan( + store=store, + encoder=encoder, + collection_name="versioned", + records=records, + expected_contract=contract, + batch_size=1, + alias_name="eps_language_phrases_active", + ) + payload = store.points["versioned"][0]["payload"] + assert len(payload["dense"]) == 1024 + assert payload["sparse_indices"] +``` + +- [ ] **Step 2: RED 확인** + +Run: `pytest -q tests/agents/language/test_indexer.py` +Expected: embedded plan이 없어 FAIL + +- [ ] **Step 3: batch pipeline과 CLI 구현** + +CLI는 source SHA를 검증하고, BGE-M3 backend/Qdrant client/store를 생성한 뒤 `build_embedded_index_plan`을 실행한다. 성공 메시지는 collection 검증과 alias 전환이 완료된 뒤에만 출력한다. `--dry-run`은 기존처럼 모델/Qdrant를 건드리지 않는다. + +- [ ] **Step 4: GREEN 확인** + +Run: `pytest -q tests/agents/language/test_indexer.py` +Run: `python scripts/index_eps_language.py --dry-run` +Expected: PASS, dry-run reports 17,902 usable records + +### Task 5: Production runtime composition + +**Files:** +- Modify: `app/core/config.py` +- Modify: `.env.example` +- Modify: `app/agents/language/composition.py` +- Test: `tests/agents/language/test_runtime_config.py` +- Test: `tests/integration/language/test_runtime_composition.py` +- Test: `tests/api/test_language_endpoint.py` + +**Interfaces:** +- Consumes: `Settings.qdrant_url`, `Settings.qdrant_api_key`, `Settings.model_cache_dir` +- Produces: Qdrant configured -> `HybridEpsRetriever`; Qdrant absent -> `_UnavailableRetriever` + +- [ ] **Step 1: actual retriever selection 실패 테스트 작성** + +```python +def test_factory_selects_hybrid_retriever_when_qdrant_is_configured(): + _, retriever, _, _, _ = _build_production_ports( + Settings( + llm_provider="ollama", + llm_base_url="http://localhost:11434/v1", + llm_model="gemma4:26b-mlx", + qdrant_url="http://qdrant:6333", + ) + ) + assert isinstance(retriever, HybridEpsRetriever) +``` + +- [ ] **Step 2: RED 확인** + +Run: `pytest -q tests/integration/language/test_runtime_composition.py` +Expected: 현재 `_UnavailableRetriever`가 반환되어 FAIL + +- [ ] **Step 3: lazy production composition 구현** + +`QdrantClient(check_compatibility=False)`, `QdrantStore`, `FlagEmbeddingBgeM3Backend`, `BgeM3Encoder`, `build_expected_index_contract()`, `HybridEpsRetriever(reranker=None)`를 조립한다. Constructor 단계에서 Qdrant 요청이나 모델 로드를 하지 않는다. + +- [ ] **Step 4: GREEN 및 import safety 확인** + +Run: `pytest -q tests/integration/language/test_runtime_composition.py tests/api/test_language_endpoint.py` +Run: `python -c 'from app.main import app; app.openapi()'` +Expected: PASS without Qdrant/model access + +### Task 6: Offline regression, live index, API evidence + +**Files:** +- Modify: `docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md` + +**Interfaces:** +- Consumes: running local Qdrant, cached pinned BGE-M3, `data/eps_language_db.json` +- Produces: non-empty active alias and actual API retrieval metadata + +- [ ] **Step 1: offline verification** + +Run: `ruff check ` +Run: `pytest -q --ignore=tests/ocr/test_smoke_script.py` +Run: `git diff --check` +Expected: zero failures + +- [ ] **Step 2: model cache 준비** + +Run: `python scripts/download_language_models.py --cache-dir .model-cache` +Expected: pinned BGE-M3 revision cache present. 다운로드 권한이 필요한 경우 실행 직전에 승인을 요청한다. + +- [ ] **Step 3: 실제 index 생성** + +Run: `python scripts/index_eps_language.py --source data/eps_language_db.json --qdrant-url --embedding-model-path .model-cache/bge-m3/5617a9f61b028005a4858fdac845db406aefb181 --switch-alias` +Expected: 17,902 points, active alias switched only after verification + +- [ ] **Step 4: 실제 retrieval/API 확인** + +Run: configured `POST /internal/v1/language-assistant` +Expected: HTTP 200, retrieval metadata has dataset revision/reference ids, warning codes exclude `RETRIEVAL_UNAVAILABLE` + +- [ ] **Step 5: Evidence 기록 및 최종 검증** + +Evidence에 commands, exit codes, collection/alias/point count, API status와 남은 미검증 범위를 기록한다. `.env`, token, secret이 변경 파일에 없음을 확인한다. diff --git a/pyproject.toml b/pyproject.toml index 46c62d6..c80a743 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,11 @@ intent-ax = [ "peft>=0.13,<1", "bitsandbytes>=0.44,<1", ] +language-retrieval = [ + "qdrant-client>=1.19,<2", + "FlagEmbedding>=1.3,<2", + "huggingface-hub>=0.36,<2", +] [tool.setuptools.packages.find] where = ["."] include = ["app*"] diff --git a/scripts/download_language_models.py b/scripts/download_language_models.py index 22e88db..01c08e5 100644 --- a/scripts/download_language_models.py +++ b/scripts/download_language_models.py @@ -17,14 +17,18 @@ import sys from pathlib import Path +from app.agents.language.retrieval.manifest import ( + BGE_M3_MODEL_REPO, + BGE_M3_REVISION, +) + # 정확한 모델 리비전 — 변경 금지 (T13 계약) -BGE_M3_REVISION = "5617a9f61b028005a4858fdac845db406aefb181" BGE_RERANKER_REVISION = "953dc6f6f85ac1e88eb36f5f9ce67a74a6edbc22" MODEL_SPECS: list[dict[str, str]] = [ { "name": "bge-m3", - "repo": "BAAI/bge-m3", + "repo": BGE_M3_MODEL_REPO, "revision": BGE_M3_REVISION, }, { diff --git a/scripts/index_eps_language.py b/scripts/index_eps_language.py index ed2cb84..a78de01 100644 --- a/scripts/index_eps_language.py +++ b/scripts/index_eps_language.py @@ -1,10 +1,28 @@ import argparse import hashlib import json +import os import sys from pathlib import Path -from app.agents.language.retrieval.indexer import clean_eps_data, generate_collection_name +from qdrant_client import QdrantClient + +from app.agents.language.retrieval.encoder import ( + BgeM3Encoder, + FlagEmbeddingBgeM3Backend, +) +from app.agents.language.retrieval.indexer import ( + build_embedded_index_plan, + clean_eps_data, + generate_collection_name, +) +from app.agents.language.retrieval.manifest import ( + BGE_M3_REVISION, + EPS_DATASET_REVISION, + QDRANT_COLLECTION_ALIAS, + build_expected_index_contract, +) +from app.agents.language.retrieval.qdrant_store import QdrantStore def main() -> None: @@ -12,17 +30,20 @@ def main() -> None: parser.add_argument("--source", type=str, default="data/eps_language_db.json") parser.add_argument("--qdrant-url", type=str, default="http://localhost:6333") parser.add_argument( - "--collection-alias", type=str, default="eps_language_phrases" + "--collection-alias", type=str, default=QDRANT_COLLECTION_ALIAS ) parser.add_argument( - "--embedding-model-path", type=str, default="/models/bge-m3" + "--embedding-model-path", + type=str, + default=f"/data/model-cache/bge-m3/{BGE_M3_REVISION}", ) parser.add_argument( "--embedding-model-revision", type=str, - default="5617a9f61b028005a4858fdac845db406aefb181", + default=BGE_M3_REVISION, ) parser.add_argument("--batch-size", type=int, default=100) + parser.add_argument("--use-fp16", action="store_true") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--switch-alias", action="store_true") @@ -36,6 +57,12 @@ def main() -> None: raw_bytes = source_path.read_bytes() dataset_sha = hashlib.sha256(raw_bytes).hexdigest() dataset_revision = f"sha256:{dataset_sha}" + if dataset_revision != EPS_DATASET_REVISION: + print( + f"Dataset revision mismatch: {dataset_revision}", + file=sys.stderr, + ) + sys.exit(1) raw_data = json.loads(raw_bytes) cleaning_res = clean_eps_data(raw_data, dataset_revision=dataset_revision) @@ -56,9 +83,41 @@ def main() -> None: print("Dry-run complete. Exiting without model load or Qdrant mutation.") return + model_path = Path(args.embedding_model_path) + if not model_path.exists(): + print(f"Embedding model not found: {model_path}", file=sys.stderr) + sys.exit(1) + + qdrant_client = QdrantClient( + url=args.qdrant_url, + api_key=os.getenv("FOWOCO_QDRANT_API_KEY"), + check_compatibility=False, + ) + store = QdrantStore( + client=qdrant_client, + collection_alias=args.collection_alias, + ) + encoder = BgeM3Encoder( + backend=FlagEmbeddingBgeM3Backend( + str(model_path), + use_fp16=args.use_fp16, + ) + ) print("Executing full indexing plan...") - # Full Qdrant connection and model load would occur here in production - print("Indexing completed successfully.") + build_embedded_index_plan( + store=store, + encoder=encoder, + collection_name=coll_name, + records=cleaning_res.usable_records, + expected_contract=build_expected_index_contract(), + batch_size=args.batch_size, + switch_alias=args.switch_alias, + alias_name=args.collection_alias, + ) + print( + f"Indexing completed successfully: {coll_name} " + f"({len(cleaning_res.usable_records)} points)" + ) if __name__ == "__main__": diff --git a/tests/agents/language/test_indexer.py b/tests/agents/language/test_indexer.py index 0dff8f2..a0b8332 100644 --- a/tests/agents/language/test_indexer.py +++ b/tests/agents/language/test_indexer.py @@ -4,16 +4,17 @@ import pytest -from app.agents.language.ports import EpsIndexStore +from app.agents.language.ports import DenseSparseEncoder, EpsIndexStore from app.agents.language.retrieval.indexer import ( CollectionSpec, EpsCleaningResult, + build_embedded_index_plan, build_index_plan, clean_eps_data, compute_point_id, generate_collection_name, ) -from app.agents.language.retrieval.models import ExpectedIndexContract +from app.agents.language.retrieval.models import ExpectedIndexContract, HybridVector class FakeEpsIndexStore(EpsIndexStore): @@ -79,6 +80,22 @@ def swap_alias(self, alias_name: str, collection_name: str) -> None: self.aliases[alias_name] = collection_name +class FakeDenseSparseEncoder(DenseSparseEncoder): + def __init__(self) -> None: + self.batches: list[tuple[str, ...]] = [] + + def encode_queries(self, texts: list[str]) -> tuple[HybridVector, ...]: + self.batches.append(tuple(texts)) + return tuple( + HybridVector( + dense=(0.1,) * 1024, + sparse_indices=(1, 9), + sparse_values=(0.5, 0.2), + ) + for _ in texts + ) + + @pytest.fixture def minimal_fixture_path() -> Path: return Path("tests/fixtures/language/eps_minimal.json") @@ -203,14 +220,47 @@ def test_new_collection_is_verified_before_alias_switch() -> None: records=records, expected_contract=contract, switch_alias=True, - alias_name="eps_language_phrases", + alias_name="eps_language_phrases_active", + ) + assert store.aliases.get("eps_language_phrases_active") == coll_name + + +def test_embedded_index_plan_attaches_vectors_before_upsert( + minimal_fixture_path: Path, +) -> None: + raw = json.loads(minimal_fixture_path.read_text(encoding="utf-8")) + records = clean_eps_data(raw).usable_records + contract = ExpectedIndexContract( + dataset_revision=records[0]["dataset_revision"], + embedding_model_repo="BAAI/bge-m3", + embedding_model_revision="5617a9f61b028005a4858fdac845db406aefb181", + index_contract_version="eps-language-index-v1", + point_count=1, + ) + store = FakeEpsIndexStore() + encoder = FakeDenseSparseEncoder() + + build_embedded_index_plan( + store=store, + encoder=encoder, + collection_name="versioned", + records=records, + expected_contract=contract, + batch_size=1, + switch_alias=True, ) - assert store.aliases.get("eps_language_phrases") == coll_name + + payload = store.points["versioned"][0]["payload"] + assert len(payload["dense"]) == 1024 + assert payload["sparse_indices"] == [1, 9] + assert payload["sparse_values"] == [0.5, 0.2] + assert encoder.batches == [("고맙습니다.",)] + assert store.aliases["eps_language_phrases_active"] == "versioned" def test_failed_verification_keeps_old_alias() -> None: store = FakeEpsIndexStore() - store.aliases["eps_language_phrases"] = "old_collection" + store.aliases["eps_language_phrases_active"] = "old_collection" store.fail_verification = True contract = ExpectedIndexContract( dataset_revision="sha256:29106c33d43ccdd8453623ac1a0af44e0201d7c7cc1cc68c3fb438e0ccc61c6d", @@ -226,9 +276,9 @@ def test_failed_verification_keeps_old_alias() -> None: records=[], expected_contract=contract, switch_alias=True, - alias_name="eps_language_phrases", + alias_name="eps_language_phrases_active", ) - assert store.aliases["eps_language_phrases"] == "old_collection" + assert store.aliases["eps_language_phrases_active"] == "old_collection" def test_expected_count_must_match() -> None: diff --git a/tests/agents/language/test_ollama_generation_port.py b/tests/agents/language/test_ollama_generation_port.py index 0a38a6a..1a637c0 100644 --- a/tests/agents/language/test_ollama_generation_port.py +++ b/tests/agents/language/test_ollama_generation_port.py @@ -70,6 +70,39 @@ def handle_request(request: httpx.Request) -> httpx.Response: assert "submission_method" in system_prompt +def test_ollama_adapter_disables_thinking_for_structured_generation() -> None: + captured_requests: list[httpx.Request] = [] + + def handle_request(request: httpx.Request) -> httpx.Response: + captured_requests.append(request) + return httpx.Response( + 200, + json={ + "model": "gemma4:26b-mlx", + "message": { + "role": "assistant", + "content": _valid_easy_korean_content(), + }, + "done": True, + }, + ) + + port = OllamaGenerationPort( + base_url="http://localhost:11434", + model="gemma4:26b-mlx", + transport=httpx.MockTransport(handle_request), + ) + + port.generate( + operation="easy_korean", + payload={}, + response_model=EasyKoreanDraft, + ) + + request_body = json.loads(captured_requests[0].content) + assert request_body["think"] is False + + def test_ollama_adapter_parses_single_json_code_fence() -> None: fenced_content = f"```json\n{_valid_easy_korean_content()}\n```" diff --git a/tests/agents/language/test_retrieval_service.py b/tests/agents/language/test_retrieval_service.py index 5d7fa92..de7c72b 100644 --- a/tests/agents/language/test_retrieval_service.py +++ b/tests/agents/language/test_retrieval_service.py @@ -5,7 +5,12 @@ from app.agents.language.contracts import WarningCode from app.agents.language.queries import SearchQuery -from app.agents.language.retrieval.encoder import BGEM3Backend, BgeM3Encoder, RawBgeBatch +from app.agents.language.retrieval.encoder import ( + BGEM3Backend, + BgeM3Encoder, + FlagEmbeddingBgeM3Backend, + RawBgeBatch, +) from app.agents.language.retrieval.models import ( EpsReference, ExpectedIndexContract, @@ -152,6 +157,39 @@ def test_encoder_batches_all_three_queries_once() -> None: assert backend.call_count == 1 +def test_flag_embedding_backend_converts_dense_and_lexical_weights() -> None: + class FakeTokenizer: + def encode(self, text: str, *, add_special_tokens: bool) -> list[int]: + assert text == "고맙습니다" + assert add_special_tokens is True + return [1, 2, 3] + + class FakeFlagModel: + tokenizer = FakeTokenizer() + + def encode(self, texts: Sequence[str], **kwargs: object) -> dict[str, object]: + assert tuple(texts) == ("고맙습니다",) + assert kwargs == { + "max_length": 128, + "return_dense": True, + "return_sparse": True, + "return_colbert_vecs": False, + } + return { + "dense_vecs": [[0.1] * 1024], + "lexical_weights": [{"9": 0.2, "1": 0.5}], + } + + backend = FlagEmbeddingBgeM3Backend("/models/bge-m3") + backend._model = FakeFlagModel() + + assert backend.token_count("고맙습니다") == 3 + result = backend.encode_queries(("고맙습니다",)) + + assert len(result.dense_vectors[0]) == 1024 + assert result.lexical_weights[0] == {9: 0.2, 1: 0.5} + + def test_encoder_requests_dense_and_sparse_only() -> None: backend = FakeBGEM3Backend() encoder = BgeM3Encoder(backend=backend) diff --git a/tests/integration/language/test_compose_config.py b/tests/integration/language/test_compose_config.py index ed97dd0..5bf5197 100644 --- a/tests/integration/language/test_compose_config.py +++ b/tests/integration/language/test_compose_config.py @@ -6,9 +6,25 @@ from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[3] +@pytest.mark.parametrize("compose_file", ("compose.yml", "compose.test.yml")) +def test_qdrant_healthcheck_uses_available_bash_tcp_probe( + compose_file: str, +) -> None: + import yaml + + data = yaml.safe_load((ROOT / compose_file).read_text()) + command = data["services"]["qdrant"]["healthcheck"]["test"] + + assert command[:2] == ["CMD", "/bin/bash"] + assert "/dev/tcp/127.0.0.1/6333" in command[-1] + assert "wget" not in " ".join(command) + + class TestComposeProdConfig: """compose.yml 구조 검증 — 파일 파싱만, 외부 연결 없음.""" diff --git a/tests/integration/language/test_qdrant_retrieval.py b/tests/integration/language/test_qdrant_retrieval.py index f16da93..ffac1bb 100644 --- a/tests/integration/language/test_qdrant_retrieval.py +++ b/tests/integration/language/test_qdrant_retrieval.py @@ -24,7 +24,7 @@ def test_real_store_mock_create_and_verify( mock_client = MagicMock() mock_client.get_aliases.return_value.aliases = [ MagicMock( - alias_name="eps_language_phrases", + alias_name="eps_language_phrases_active", collection_name="eps_language_phrases_29106c33d43c_5617a9f61b02", ) ] @@ -53,6 +53,81 @@ def test_real_store_mock_create_and_verify( assert handle.collection_name == "eps_language_phrases_29106c33d43c_5617a9f61b02" +def test_real_store_verifies_new_collection_before_alias_switch( + expected_contract: ExpectedIndexContract, +) -> None: + mock_client = MagicMock() + mock_client.get_collection.return_value.points_count = 100 + dense_param = MagicMock(size=1024, distance="Cosine") + mock_client.get_collection.return_value.config.params.vectors = { + "korean_dense": dense_param + } + mock_client.get_collection.return_value.config.params.sparse_vectors = { + "korean_sparse": MagicMock() + } + mock_client.count.return_value.count = 100 + + store = QdrantStore(client=mock_client) + + store.verify_collection( + "new_collection", + expected_count=100, + spec=MagicMock(dense_vector_size=1024), + expected_languages=("en",), + expected_contract=expected_contract, + ) + + +def test_real_store_rejects_wrong_collection_point_count( + expected_contract: ExpectedIndexContract, +) -> None: + mock_client = MagicMock() + mock_client.get_collection.return_value.points_count = 99 + store = QdrantStore(client=mock_client) + + with pytest.raises(ValueError, match="RETRIEVAL_UNAVAILABLE"): + store.verify_collection( + "new_collection", + expected_count=100, + spec=MagicMock(dense_vector_size=1024), + expected_languages=("en",), + expected_contract=expected_contract, + ) + + +def test_real_store_replaces_existing_active_alias() -> None: + mock_client = MagicMock() + mock_client.get_aliases.return_value.aliases = [ + MagicMock( + alias_name="eps_language_phrases_active", + collection_name="old_collection", + ) + ] + store = QdrantStore(client=mock_client) + + store.swap_alias("eps_language_phrases_active", "new_collection") + + operations = mock_client.update_collection_aliases.call_args.kwargs[ + "change_aliases_operations" + ] + assert len(operations) == 2 + assert operations[0].delete_alias.alias_name == "eps_language_phrases_active" + assert operations[1].create_alias.collection_name == "new_collection" + + +def test_real_store_uses_qdrant_1_19_payload_schema_type() -> None: + from qdrant_client import models as qmodels + + mock_client = MagicMock() + store = QdrantStore(client=mock_client) + + store.ensure_payload_indexes("collection", ("target_language",)) + + assert mock_client.create_payload_index.call_args.kwargs[ + "field_schema" + ] == qmodels.PayloadSchemaType.KEYWORD + + def test_real_store_mock_search_many( expected_contract: ExpectedIndexContract, ) -> None: diff --git a/tests/integration/language/test_runtime_composition.py b/tests/integration/language/test_runtime_composition.py index f035555..e9096ce 100644 --- a/tests/integration/language/test_runtime_composition.py +++ b/tests/integration/language/test_runtime_composition.py @@ -5,6 +5,7 @@ from app.agents.language.generation.ollama import OllamaGenerationPort from app.agents.language.ports import NoopTraceSink, SemanticValidationDecision from app.agents.language.retrieval.models import RetrievalResult +from app.agents.language.retrieval.service import HybridEpsRetriever from app.agents.language.service import LanguageAssistantService from app.core.config import Settings from tests.agents.language.fakes import ( @@ -94,6 +95,21 @@ def test_factory_selects_native_adapter_for_ollama_provider() -> None: assert isinstance(generator, OllamaGenerationPort) +def test_factory_selects_hybrid_retriever_when_qdrant_is_configured() -> None: + from app.agents.language.composition import _build_production_ports + + _, retriever, _, _, _ = _build_production_ports( + Settings( + llm_provider="ollama", + llm_base_url="http://localhost:11434/v1", + llm_model="gemma4:26b-mlx", + qdrant_url="http://qdrant:6333", + ) + ) + + assert isinstance(retriever, HybridEpsRetriever) + + def test_factory_rejects_missing_generation_settings() -> None: _, unavailable, build_service = _composition_types() diff --git a/uv.lock b/uv.lock index d023e1d..6e3fb80 100644 --- a/uv.lock +++ b/uv.lock @@ -2,8 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version < '3.12'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -25,6 +35,146 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/db/253133d7e7cb40d3af384bb2f5c0b4a2b7fdcffbc95c688cc67a20a3c103/accelerate-1.14.0-py3-none-any.whl", hash = "sha256:e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6", size = 389246, upload-time = "2026-06-11T13:45:50.477Z" }, ] +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.5" @@ -56,6 +206,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "bitsandbytes" version = "0.50.0" @@ -255,6 +414,41 @@ nvtx = [ { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] +[[package]] +name = "datasets" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dill" }, + { name = "filelock" }, + { name = "fsspec", extra = ["http"] }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "multiprocess" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pyarrow" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/5b/836516269d4f618efe621661cfb6f9acc57e6f95265db3efaee48a5ffe04/datasets-5.0.1.tar.gz", hash = "sha256:ce22bb851efd7494f08aad33b940803784434f6e77763d00679a0dc45fcf686a", size = 641498, upload-time = "2026-07-28T11:09:12.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/0b/98fc6eb83333508ca5f44c52b3e287ea8137a0ad582714e2cbc67a02154b/datasets-5.0.1-py3-none-any.whl", hash = "sha256:9fbf73688f8c18f7529b4fe592abd04015f81d1e58001e4bac73ffb2b39d7cc4", size = 559079, upload-time = "2026-07-28T11:09:10.266Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -289,6 +483,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, ] +[[package]] +name = "flagembedding" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accelerate" }, + { name = "datasets" }, + { name = "ir-datasets" }, + { name = "peft" }, + { name = "protobuf" }, + { name = "sentence-transformers" }, + { name = "sentencepiece" }, + { name = "torch" }, + { name = "transformers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/51/22dc9c448577542b3c3d75ae48eda0a97dfa29552a56cddd87437fec5940/flagembedding-1.4.0.tar.gz", hash = "sha256:2079c14ecf0341d64519785e53c0401122a752e5b6a0ac497f23c02f0d21b3b9", size = 175024, upload-time = "2026-04-22T16:09:54.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/70/3593ccb1299f8440369fd3c542ef2f5c09718eb8c7628614303e65dd38f8/flagembedding-1.4.0-py3-none-any.whl", hash = "sha256:fb1856b312851591341cf4533187350e9ce43f66bbf195c66f25a73266ff7db9", size = 247714, upload-time = "2026-04-22T16:09:52.448Z" }, +] + [[package]] name = "fowoco-ai" version = "0.1.0" @@ -326,14 +540,21 @@ intent-ax = [ { name = "torch" }, { name = "transformers" }, ] +language-retrieval = [ + { name = "flagembedding" }, + { name = "huggingface-hub" }, + { name = "qdrant-client" }, +] [package.metadata] requires-dist = [ { name = "accelerate", marker = "extra == 'intent'", specifier = ">=0.34,<2" }, { name = "bitsandbytes", marker = "extra == 'intent-ax'", specifier = ">=0.44,<1" }, { name = "fastapi", specifier = ">=0.115,<1" }, + { name = "flagembedding", marker = "extra == 'language-retrieval'", specifier = ">=1.3,<2" }, { name = "fowoco-ai", extras = ["intent"], marker = "extra == 'intent-ax'" }, { name = "httpx", specifier = ">=0.28,<1" }, + { name = "huggingface-hub", marker = "extra == 'language-retrieval'", specifier = ">=0.36,<2" }, { name = "hwp2hwpx", specifier = "==1.0.1" }, { name = "langgraph", specifier = ">=0.2,<1" }, { name = "ms-cfb", specifier = "==0.0.6" }, @@ -346,20 +567,182 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25,<1" }, { name = "python-dotenv", specifier = ">=1.0,<2" }, { name = "python-multipart", specifier = ">=0.0.20,<1" }, + { name = "qdrant-client", marker = "extra == 'language-retrieval'", specifier = ">=1.19,<2" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.12,<1" }, { name = "torch", marker = "extra == 'intent'", specifier = ">=2.2,<3" }, { name = "transformers", marker = "extra == 'intent'", specifier = ">=4.46,<5" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" }, ] -provides-extras = ["dev", "knowledge", "intent", "intent-ax"] +provides-extras = ["dev", "knowledge", "intent", "intent-ax", "language-retrieval"] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] [[package]] name = "fsspec" -version = "2026.7.0" +version = "2026.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + +[package.optional-dependencies] +http = [ + { name = "aiohttp" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f6/3b781cd07a715ea5f5125ae264226e7fc4d87603d6d3955022cabfdc5da2/grpcio-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f", size = 6338720, upload-time = "2026-07-23T15:19:13.177Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/d14833d15d5984e366f1b027fa78bd038c9b028c66880bffb0f5a4d25ee2/grpcio-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223", size = 12178773, upload-time = "2026-07-23T15:19:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/6b/98/8acbb416544e7871132d8e42a07ed70c802d70e6a16c6009e505a34d32a4/grpcio-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0", size = 6921203, upload-time = "2026-07-23T15:19:17.824Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/0fdbfaf4fc54e5c88f6bce4008a065092fe7fbc4460eb5617ae8b20fd505/grpcio-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d", size = 7648508, upload-time = "2026-07-23T15:19:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ea/107b9dbb2ed3ad14dd774fd3dde7d29ff9938a6c198654becb2c3a0e9a6a/grpcio-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9", size = 7079466, upload-time = "2026-07-23T15:19:21.478Z" }, + { url = "https://files.pythonhosted.org/packages/3b/06/9fa9941089e6fae83b060b6ce61c1e81053e52decae43197245f45e07d36/grpcio-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745", size = 7605583, upload-time = "2026-07-23T15:19:23.74Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/f10fb56062dc2771c630827a82d9ad0ecd05cad572ea3b08d49f6631680a/grpcio-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617", size = 8637810, upload-time = "2026-07-23T15:19:25.536Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/f84927258f6a1b6ea6dea661fdc6de859b35e560c96f3012d15ccd39f85e/grpcio-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969", size = 8008021, upload-time = "2026-07-23T15:19:27.863Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/cdf72161397ccd29d4ca2192f641524536c9cf54ad948c9dd0e0e01138fa/grpcio-1.83.0-cp311-cp311-win32.whl", hash = "sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45", size = 4404376, upload-time = "2026-07-23T15:19:30.137Z" }, + { url = "https://files.pythonhosted.org/packages/df/ed/e0ffeb4c848699c194dc9fb6a29ab29bcb2b6aac8c416bf18c51bfe8242c/grpcio-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49", size = 5164469, upload-time = "2026-07-23T15:19:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, ] [[package]] @@ -371,6 +754,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + [[package]] name = "hf-xet" version = "1.6.0" @@ -395,6 +791,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, ] +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -466,6 +871,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + [[package]] name = "huggingface-hub" version = "0.36.2" @@ -494,6 +904,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/b5/0e6ff236cc92db5a15afd9b38b34c7bf219ee7648365daab80f31e080dbd/hwp2hwpx-1.0.1-py3-none-any.whl", hash = "sha256:e44606b65d840a33ceae42272194f1ae63665676fa01ea88bd763fede72798ea", size = 2193714, upload-time = "2026-05-14T07:53:40.44Z" }, ] +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -512,6 +931,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "ir-datasets" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "lz4" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/03/44fe25e99981279ef23506072708757c1950d63f248e6402f43017abf953/ir_datasets-0.6.3.tar.gz", hash = "sha256:e8b82870b556a2ef30cc965cf133411c013fe0021f3448de8be8cfdc14f58a9f", size = 837400, upload-time = "2026-07-18T11:59:37.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/8e/1e56e401daf67c1f8acebf3791e9fe1e1a6b4fe605d8e6d1c6579d8fdce1/ir_datasets-0.6.3-py3-none-any.whl", hash = "sha256:8188ad99408dc042b3be7493fa8a9d76cf04681ca1ea010e97a5f93a0aaf1dbd", size = 947801, upload-time = "2026-07-18T11:59:36.561Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -524,6 +961,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -658,6 +1104,156 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/7a/58602b770741bc84b0b35b580914f335f6663f4ea699b95eb12b074e70b8/langsmith-0.10.15-py3-none-any.whl", hash = "sha256:7afd7979a9cdf846a88c980e0a31ed518c33631d29e672adbfbb33446f3817cf", size = 731606, upload-time = "2026-07-31T18:15:16.471Z" }, ] +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" }, + { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" }, + { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" }, + { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" }, + { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -754,6 +1350,152 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/e3/4dfc661edad9e7ec031b85534441c08a5283947d1706c49b93f5c93b632d/ms_cfb-0.0.6-py3-none-any.whl", hash = "sha256:785f05a306262e95496551cebe6e17290a4ac2f69cabd08b577e8726e66365d7", size = 30405, upload-time = "2026-03-30T13:23:07.956Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "multiprocess" +version = "0.70.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dill" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/f2/e783ac7f2aeeed14e9e12801f22529cc7e6b7ab80928d6dcce4e9f00922d/multiprocess-0.70.19.tar.gz", hash = "sha256:952021e0e6c55a4a9fe4cd787895b86e239a40e76802a789d6305398d3975897", size = 2079989, upload-time = "2026-01-19T06:47:39.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/aa/714635c727dbfc251139226fa4eaf1b07f00dc12d9cd2eb25f931adaf873/multiprocess-0.70.19-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1bbf1b69af1cf64cd05f65337d9215b88079ec819cd0ea7bac4dab84e162efe7", size = 144743, upload-time = "2026-01-19T06:47:24.562Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/155f6abf5e6b5d9cef29b6d0167c180846157a4aca9b9bee1a217f67c959/multiprocess-0.70.19-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5be9ec7f0c1c49a4f4a6fd20d5dda4aeabc2d39a50f4ad53720f1cd02b3a7c2e", size = 144738, upload-time = "2026-01-19T06:47:26.636Z" }, + { url = "https://files.pythonhosted.org/packages/af/cb/f421c2869d75750a4f32301cc20c4b63fab6376e9a75c8e5e655bdeb3d9b/multiprocess-0.70.19-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1c3dce098845a0db43b32a0b76a228ca059a668071cfeaa0f40c36c0b1585d45", size = 144741, upload-time = "2026-01-19T06:47:27.985Z" }, + { url = "https://files.pythonhosted.org/packages/e3/45/8004d1e6b9185c1a444d6b55ac5682acf9d98035e54386d967366035a03a/multiprocess-0.70.19-py310-none-any.whl", hash = "sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87", size = 134948, upload-time = "2026-01-19T06:47:32.325Z" }, + { url = "https://files.pythonhosted.org/packages/86/c2/dec9722dc3474c164a0b6bcd9a7ed7da542c98af8cabce05374abab35edd/multiprocess-0.70.19-py311-none-any.whl", hash = "sha256:928851ae7973aea4ce0eaf330bbdafb2e01398a91518d5c8818802845564f45c", size = 144457, upload-time = "2026-01-19T06:47:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl", hash = "sha256:3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28", size = 150281, upload-time = "2026-01-19T06:47:35.037Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/d2c27e03cb84251dfe7249b8e82923643c6d48fa4883b9476b025e7dc7eb/multiprocess-0.70.19-py313-none-any.whl", hash = "sha256:8d5eb4ec5017ba2fab4e34a747c6d2c2b6fecfe9e7236e77988db91580ada952", size = 156414, upload-time = "2026-01-19T06:47:35.915Z" }, + { url = "https://files.pythonhosted.org/packages/a0/61/af9115673a5870fd885247e2f1b68c4f1197737da315b520a91c757a861a/multiprocess-0.70.19-py314-none-any.whl", hash = "sha256:e8cc7fbdff15c0613f0a1f1f8744bef961b0a164c0ca29bdff53e9d2d93c5e5f", size = 160318, upload-time = "2026-01-19T06:47:37.497Z" }, + { url = "https://files.pythonhosted.org/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5", size = 133477, upload-time = "2026-01-19T06:47:38.619Z" }, +] + +[[package]] +name = "narwhals" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, +] + [[package]] name = "networkx" version = "3.6.1" @@ -768,7 +1510,9 @@ name = "numpy" version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.12'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ @@ -850,7 +1594,15 @@ name = "numpy" version = "2.5.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } wheels = [ @@ -1185,6 +1937,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ef/f1fd7431d635bf20015489bf0bd69c17fff1018de773540f651455a3916b/pandas-3.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2946e77e4a53cd248cbde631a12f0e51c8324ce354c3eba4d20147c1ad6f4282", size = 10397178, upload-time = "2026-07-22T22:17:48.274Z" }, + { url = "https://files.pythonhosted.org/packages/31/b4/0eafac990a431561187694126de01f9b12559549b4d86360c0c4bd870fde/pandas-3.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71ecc8fb7ed1a7aa4392316b5309a6347e8e7f832f38fd897846b3a1457a9298", size = 9990736, upload-time = "2026-07-22T22:17:52.388Z" }, + { url = "https://files.pythonhosted.org/packages/de/21/359880af3ea9b7cb23bea5b51e8e70ef3866c03be09da9a2787e18e330a8/pandas-3.0.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b173f5951ff6b8b0ec7675e20dff3c97b7e7a57dfcce387c2d7c5afe87cb7899", size = 10814438, upload-time = "2026-07-22T22:17:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/d6cc4d7e508bbccf5d6027314a8312bc7ac73d0ec7f195f53838daafab40/pandas-3.0.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c0cf1dd9b55a22d105fc46c1b489af3bd42264fcba7c66297bf47a9a1d9c78a", size = 11323634, upload-time = "2026-07-22T22:17:56.858Z" }, + { url = "https://files.pythonhosted.org/packages/70/2b/d5f0a8c90dd0ae04e64ba53b871afb796ec026b615086d382ddc2ade729b/pandas-3.0.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0fac0010c75e4efb6b99e249c183a8993ce0dc95c240f9b120a5e67c727b7928", size = 11850860, upload-time = "2026-07-22T22:17:59.1Z" }, + { url = "https://files.pythonhosted.org/packages/5c/30/183aec2e19adf778a98d29b5729a0a68f4cc4ebf9b9c3b70d0297355bcb1/pandas-3.0.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:08d24fe11a17dc33bd6e937dc9c665f9cba08fbdc9f657f405713515febe300d", size = 12411100, upload-time = "2026-07-22T22:18:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9a/31f4983f191af51ab2a8f2d0c7b33dff3a84da26533f982fff02c2f9e28b/pandas-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b1261758dfb6cf12c3cff8300e21cefad30e7ec709abb4c24ac7318e6a52462a", size = 9968804, upload-time = "2026-07-22T22:18:03.903Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/7886c89a39045c69ad82cbceaf3343810480c8ef49a216319ce8183860a6/pandas-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:679f4e85b30ddb1515458ab1e788d3e260eae369b1f78da7a3aa4cac8ebf4a2a", size = 9205447, upload-time = "2026-07-22T22:18:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, + { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + [[package]] name = "peft" version = "0.20.0" @@ -1300,6 +2107,144 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "portalocker" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -1328,6 +2273,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "pyarrow" +version = "25.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/98/ae2b5acf9876dbeffa6f320776242c52caab062df55c8ac5501ed2679e74/pyarrow-25.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:2e3b6544e26e393fe2cd530f523e36c1c8d3c345bbbb60cca3fd866be8322517", size = 35939080, upload-time = "2026-07-10T08:26:04.53Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/3de2a968edbd496c86cb8b932cdbee2d4b08c4a28e9884a15e5c705a646b/pyarrow-25.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:b724d127783b4c19f088fcdfc844cbc318809246a30307bcabd5ed02045e890e", size = 37633420, upload-time = "2026-07-10T08:26:10.354Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/8399243a4ce080426ec37db18d5e29148b7ec960a8a8c7f9059a7bf6ef0a/pyarrow-25.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:244f98a595f70fa4fd35faa7508c4ae67e14a173397a4b3b49d2b3c360fb0062", size = 46861050, upload-time = "2026-07-10T08:26:16.397Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/72d704b02bc5fc6d06954d76a0208c1e79cad3ab370f6d6a91ffe5078870/pyarrow-25.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0222f0071d13313962a88d21bf28b80d355ac39d81bfa6ff3fe00eeaf748e4be", size = 50056458, upload-time = "2026-07-10T08:26:23.271Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/3c31a60b6403d63cad2e0f829096f5fc5763a129ead4207a5d4690b96448/pyarrow-25.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b58726f118c079f9d4ed7e904975d4f15fd69d0741ba511a4e2dcaa4ef16354f", size = 49957793, upload-time = "2026-07-10T08:26:30.232Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/8f8a019061f9863a831915329264372a87ed25eaf9109ce56eb0e84012c5/pyarrow-25.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:38a2c887cb3883e241b70201688db34133b6dfadd04f03c8f9213df53770c18e", size = 53100544, upload-time = "2026-07-10T08:26:36.414Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e2/738071e95c5ddad7b3dfc12f569ffa992db89d7d7b4a95258fd184191249/pyarrow-25.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:161649d60a7a46c613a19fd795763ea8a88c36ba997dd99d9bc66e6794ee36e8", size = 27848311, upload-time = "2026-07-10T08:26:41.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/fdd3a4377807b7dcabe2d4b5aa99dbbc98e2e5df3f1ca4e7f0aec492d987/pyarrow-25.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:149730a3d1f0fb59d663a0b8aa210adfd9c17c27cd94a0d143e60daea8320d4e", size = 35850884, upload-time = "2026-07-10T08:26:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/bf/71/9f053177a7709b8c90abb00a2375b916286f9f0d6cfb21a5cadd4ef811e8/pyarrow-25.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0721332c30fdd453fdd1fc203b2ac1f4c9db5aea28fa38d41f2574c4b068b9ec", size = 37616197, upload-time = "2026-07-10T08:26:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/1a/22bfb6597dcdc861fa83c39c06e1457cb56f698940eff42fbb25de30e8e5/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fa1482b3da10cac2d4db6e26b81da543e237616af2ef6d466018b31ca586496f", size = 46841966, upload-time = "2026-07-10T08:27:07.685Z" }, + { url = "https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778", size = 50088993, upload-time = "2026-07-10T08:27:14.268Z" }, + { url = "https://files.pythonhosted.org/packages/98/ee/d822e1ee31fe31ec5d057210e0605c950b975dcd8d9a332976cc859a9df8/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20887a762dd61dcc530f93a140840ab1f6aa7836b33270e42d627ab3cf11e537", size = 49941005, upload-time = "2026-07-10T08:27:21.274Z" }, + { url = "https://files.pythonhosted.org/packages/33/1b/207a90cc64619a095eb75a263ae069735f2810056d43c667befd573ec083/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58d1ab556b0cea1c93fdb799b24ad58adb2f2a2788dbce782a94f64ae1a5cc9b", size = 53112355, upload-time = "2026-07-10T08:27:27.911Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fe/81d1e5f8beed15c01e98649d5c6e2167b67fd395884a2488f18bf1cf0dba/pyarrow-25.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f356afe61186395c861d5cd63dc21ff7d5fa335012a4668d979257df7fea0f5", size = 27945954, upload-time = "2026-07-10T08:27:32.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c8/098ce17d778fd9d29e40bb8c5f19a40cc90c3f0b46c9057b0d7993f42f54/pyarrow-25.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8831a3ba52fa7cdb78d368d968b1dcd06171e6dff5461e16d90de91d371e47bc", size = 35844549, upload-time = "2026-07-10T08:27:37.956Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/24c28877219abf6263d909b1592c97ff82c59f13a59acbed11fc87c0654f/pyarrow-25.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:5f4bacb60f91dd2fca6c52f1b9a0012cd090e0294f1f781dc1881a247a352f8e", size = 37610397, upload-time = "2026-07-10T08:27:43.803Z" }, + { url = "https://files.pythonhosted.org/packages/53/55/6d1d5f5aff317ec5de9421594679ed51ed828fe7e2ce209327f819d801e4/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:59516c822d5fd8e544aaa0dfe72f36fed5d4c24ea8390aab1bcd31d7e959c6be", size = 46841701, upload-time = "2026-07-10T08:27:49.741Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5d/f790fb6965ab54c9da0dda7856abc75fd0d7648d865f8d603c111d203a64/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f9dbd83e91c239a1f5ee7ce13f108b5f6c0efbe40a4375260d8f08b43ad05e9", size = 50090118, upload-time = "2026-07-10T08:27:56.051Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8c/faf025357ebf31bc96777f234277aa31e2aeca6dd4ecaa391f29085473c2/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18dcc8cc50b5e72eae6fcbfc6c8776c21a007176b27a3cdec5c2f5bcf126708d", size = 49945559, upload-time = "2026-07-10T08:28:01.927Z" }, + { url = "https://files.pythonhosted.org/packages/07/a1/bd051871708ea99a5e0fc711926c26c6f2c6d0130c7aaac8093e34998af6/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4ec1895a87aa834c3b99b7a1e758747eb8bb57f922b32c0e0fa04afb8d6998b1", size = 53114238, upload-time = "2026-07-10T08:28:08.594Z" }, + { url = "https://files.pythonhosted.org/packages/7c/31/737f0c3cffcd6af647849477d1dd68045deac2e3963c3f9f211bedc48540/pyarrow-25.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:77c8d1ae46a44b4006e8db1cc977bbcc6ce4873c92f74137d68e45503b97fb18", size = 27861162, upload-time = "2026-07-10T08:28:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/581ccbcdb3d897eb2893328d68db3d52eca373bf2a7e964d0a6276b8e85b/pyarrow-25.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:72132b9a8a0a1840197794d4dea26080069b6b0981c116bc078762dc9691b21b", size = 35878945, upload-time = "2026-07-10T08:28:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/64/d1/ccb01db7329ea0411ef4fbd9b62a04d3268b36777d4e758d5e39b91ddeab/pyarrow-25.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e009ef945e498dca2f050ea10d2e9764cb44017254826fc4574fdb8d2530173b", size = 37630854, upload-time = "2026-07-10T08:28:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/2d81ba89d1e4198d0cb25fe7529de936830fdaec0db926bb52a1ef7080d4/pyarrow-25.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f57a39dbcb416345401c2e77a4373669b45fd111a1768e6cf267a7a0607ff0ec", size = 46905617, upload-time = "2026-07-10T08:28:29.376Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/0ed312ec800fb536f93783215126cee4b8977dcfeccba6f0f44df0cc87d7/pyarrow-25.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:447df764beb07c544f0178a5f6b70ef44b9ecf382b3cdfad4c2d7867353c3887", size = 50119765, upload-time = "2026-07-10T08:28:35.826Z" }, + { url = "https://files.pythonhosted.org/packages/ca/88/cab5063ba0c4d46a9f6b4b7eb1c9029dc0302d65cd5ab3510c949a386568/pyarrow-25.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac5dfeee59f9ceb4d45ba76e83b026c38c24334135bb329d8274baa49cec3c62", size = 50027563, upload-time = "2026-07-10T08:28:43.848Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fb/4d24f1b7fe2e042dc4ef315ef75e4e702d8e46fe10c37e63caff00502b03/pyarrow-25.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0f100dacf2c0f400601664a79d1a907ced4740514bb2b00917341038e2ce76f", size = 53162437, upload-time = "2026-07-10T08:28:52.819Z" }, + { url = "https://files.pythonhosted.org/packages/fa/65/da20806de93ca6ee91e72cb6a9b08b3ac890b46efc8d94a7326c651c4c81/pyarrow-25.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:2e093efbecb5317372f819228fa4b4e6157eee48d3f0a7b0303705ebf81a7104", size = 28613262, upload-time = "2026-07-10T08:29:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/86/9f/c632afb1d3ef4a7814cee236718235f3a47eac46e97eb87df40f550b6b48/pyarrow-25.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:26be35b80780d2d21f4bae3d568b1666337c3a89722cc1794c956a77017cb24e", size = 36120702, upload-time = "2026-07-10T08:28:59.577Z" }, + { url = "https://files.pythonhosted.org/packages/36/0a/093d53a0e72ad06e45d6443e00651bbc2d21af4211295086cbf4d873d3b9/pyarrow-25.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:6f4812bfbf11ca7d8faf59eb8fff8bf4dd25ce3a38b62baa010cc17a0926d1b2", size = 37750674, upload-time = "2026-07-10T08:29:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/b37fc31a69cff4bdfb8842683def5612f551b93fff6f44375e4a4a6a5535/pyarrow-25.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b8af8ceedf0c9c160fd2b63440f2d205b9404db85866c1217bfea601de7cfb50", size = 46912304, upload-time = "2026-07-10T08:29:14.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/35/5cae19ba72493e5598022468b56f6a5571f399f485bf412f157356476caa/pyarrow-25.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c70a5fd9a82bd1a702fd482bdc62d38dcb672fb2b449b1d7c0d7d1f4be7b7bfe", size = 50073652, upload-time = "2026-07-10T08:29:22.467Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a5/ddd508424bdfd5e6945765e9e2ffc687e2f6115972badc8ecf423076c407/pyarrow-25.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0490a7f8b38ffe11cc26526b50c65d111cb54ddac3717cec781806793f1244dc", size = 50058654, upload-time = "2026-07-10T08:29:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/324d0db203ff5eebe8694ec2d6ec5a23f9aaa5d02e5b8c692914c518c33c/pyarrow-25.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e83916bbcf380866b4e14255850b33323ff678dc9758411d0409cdd2523880b0", size = 53140153, upload-time = "2026-07-10T08:29:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8d/d236e9c82fe315f9128885c8be3ec719f41965a1eb6b6f4b42470904cd41/pyarrow-25.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:13240f0d3dc5932ccd0bfa90cd76d835680b9d94a7661c635df4b703d40ce849", size = 28743657, upload-time = "2026-07-10T08:29:42.742Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -1496,6 +2484,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1523,6 +2523,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/89/5ec05e8a247daa93547be53aaf7ac2159f5e4ff705cdb16e83783ac721a2/python_red_black_trees-0.0.2-py3-none-any.whl", hash = "sha256:e9203a9824cd5407219a7a104a59cd067c4731c048b8369679136ee79b83ae6b", size = 10307, upload-time = "2026-03-30T10:52:38.758Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1578,6 +2600,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qdrant-client" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "httpx", extra = ["http2"] }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "portalocker" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/33/c6e4ec45b4fca5a0b808e8804e60be54a6d7505b68b53c0b1d0d62ba86c1/qdrant_client-1.19.0.tar.gz", hash = "sha256:365395a04b0a26c309b25b7d8b1c99ef2071ec9a2b74bc8a5fd3b7a3642fe963", size = 350953, upload-time = "2026-08-04T14:32:56.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3c/480c61cc8d5a3e76bb44e86231f408c93643e5498beadbbeb381ab55d02b/qdrant_client-1.19.0-py3-none-any.whl", hash = "sha256:13602a2b3478a95ecdf42f97b93d7f703b63a3361cd912a04495a33a5ac14121", size = 396157, upload-time = "2026-08-04T14:32:55.734Z" }, +] + [[package]] name = "regex" version = "2026.7.19" @@ -1758,6 +2799,263 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "sentence-transformers" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scikit-learn" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "tokenizers" }, + { name = "torch" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/59/867381b1414a975da6c9953f48a07c05cb0629305e2d37c9bcc9764367b2/sentence_transformers-5.7.0.tar.gz", hash = "sha256:fd8c8fc35e6323631dff9f3760969ebf7980dc3cfda0ab1354bc6a774cc0e5d8", size = 466382, upload-time = "2026-08-06T12:12:33.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/c8/f63d99e354532f5b83e735dd1e001bda92495fbfde934f65d924abf2b071/sentence_transformers-5.7.0-py3-none-any.whl", hash = "sha256:b78141da3d8137e70d965866e2ca43190b9266f3d4d8752e250ded75e7136730", size = 611333, upload-time = "2026-08-06T12:12:31.881Z" }, +] + +[[package]] +name = "sentencepiece" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/33/ea3cb3839607eb175da835244a798f797f478c5ddf0e8ecdf57ea85a4c70/sentencepiece-0.2.2.tar.gz", hash = "sha256:3d2b5e824b5622038dc7b490897efe05ebbbb9e7350fc142f3ecc8789ef9bdf6", size = 8218435, upload-time = "2026-07-12T08:39:34.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/31/f23a2efaa0210b883574001b88fa64e499f798f0848a0b610fb9b384d162/sentencepiece-0.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:69e9dc8078e128286ed3b975e37c837ba96e215a50c3ef9f3f8b7ab9e5a832a0", size = 2184255, upload-time = "2026-07-12T08:38:14.855Z" }, + { url = "https://files.pythonhosted.org/packages/96/f2/1ee0ccb772d71e822f625d6cb5f0ea825835e877f28a9ef299a1291df19e/sentencepiece-0.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6dd76f3e5c8b2eb8a3a3efee787bbf5b9a66e52a048fe09cab85eca33fec6790", size = 1438545, upload-time = "2026-07-12T08:38:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/2a/92/3a6ea4a2c6dd9e7062698a5a33534ca0e20844883338ae9c6b9c122c1a9f/sentencepiece-0.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:443ac618c7a2a1377cf5c82581fbb849591d14e656d5e5a3e4682d4e36a34e4e", size = 1346997, upload-time = "2026-07-12T08:38:18.499Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3a/7839048997c7bc0c34c57526f539f835e20c7a57dc2a99f99579b11cdbef/sentencepiece-0.2.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e2aae42960392d6dcb9a72d8e1e65a97294c965071b43c7b3429a42f350250e", size = 1324282, upload-time = "2026-07-12T08:38:20.342Z" }, + { url = "https://files.pythonhosted.org/packages/06/5f/9117bf854aef817ad0d0ee9310eed0308a7e529e7eaf2e80ad9cd281ef82/sentencepiece-0.2.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1416b92f2f010333786fe6306ed2631121d5ea492219b0841e967b6765e64107", size = 1394242, upload-time = "2026-07-12T08:38:22.976Z" }, + { url = "https://files.pythonhosted.org/packages/ab/62/9e2569867e3dcff7ad6d89642a9615b9801b5cd698abe7df3b490361f66e/sentencepiece-0.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:70d4ca6f4d06df7f0ccab6fe4f49c8a712c8c8b6847b4f0af9a0e1dbb0e0337e", size = 1246268, upload-time = "2026-07-12T08:38:24.857Z" }, + { url = "https://files.pythonhosted.org/packages/96/c9/5d781d4ef1124564a45c98b9ff25d531c10cdf568ec6314a2d1946f9251c/sentencepiece-0.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:252908153eeec06c3ca3a32077e64a49d572e3d89881475b4e0f02d99d9fcc7c", size = 1190702, upload-time = "2026-07-12T08:38:26.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/13/7a562289c8d5b49ebdf3f9c1e8ab67cf14a8743b1d90c8f406bfdec36b72/sentencepiece-0.2.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1edb10e520e4bddf74d85b0f5ae74cc2d60c2b448885080bfb618bc2b3a49f6b", size = 2188384, upload-time = "2026-07-12T08:38:28.486Z" }, + { url = "https://files.pythonhosted.org/packages/85/d1/912f14fd5eae168aba726ffb6a9a2dc1c71fe7676c53da6f5c442b886d4a/sentencepiece-0.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7c06c751c19d923435a54bff4f7e66e728fad160e8da28254f133abc9725820", size = 1441553, upload-time = "2026-07-12T08:38:30.552Z" }, + { url = "https://files.pythonhosted.org/packages/bd/44/caa9cab5f261a019e2808bc5046152775dc57352ba9cbae7525e9e7a1ed4/sentencepiece-0.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38111ed1f79268f399c505028023d5eaaf0ab4e5eafceb709468b0d3323e7838", size = 1347176, upload-time = "2026-07-12T08:38:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/cd798935668cff71d309d8ff10385844ecf216b1fe454f1993ed8bf2cb91/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbce24284f51f71d10a42b7b9c964dcb9048b28f1c8e5db40bcbcb6f428cba6a", size = 1325200, upload-time = "2026-07-12T08:38:33.689Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/37e3da037318a70066ded0d51bc2a7f35491ae6338dd993d5eb1503fc3b5/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8a168b040bc61681293f79a949b5d911c8e25086f4260285b8d97ab5f1195da", size = 1397736, upload-time = "2026-07-12T08:38:35.771Z" }, + { url = "https://files.pythonhosted.org/packages/8d/11/753fca2e6b109be3ab7867abf357dfe48677fe726ae5a5363d0b54ca9450/sentencepiece-0.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7c6e7bf684dc12145bfa685d3060beaea55139134ba848289bee514ed42e7383", size = 1248030, upload-time = "2026-07-12T08:38:37.604Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0a/70efbe861ca182d7d4b6e1a20f58e043400848fa9f2915229f082e221648/sentencepiece-0.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:76ff5814db72e7462dece042d7593cdf102b8ec82c2b1cc201a2add34ee3050d", size = 1187325, upload-time = "2026-07-12T08:38:39.348Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/b3b05095c174d6e80d37d5ddc2f57c2c56237333e7bbd6079cf3243c2a8a/sentencepiece-0.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:77c3ce990b23441e5ecfa5bce181fd6f408b564aeb6d7e1d1e7de9c5612501c8", size = 2188346, upload-time = "2026-07-12T08:38:41.089Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/72ebc4acb10a06bcf7503fbc6091c8f5db68300f6aac4356c09e6c76e0e1/sentencepiece-0.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd523c4992041faa5c2b3cde62253d11a96c30d73a34afe48a486e8e2254cd1c", size = 1441434, upload-time = "2026-07-12T08:38:42.56Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/f9ea1a6844b4fa5dfe2312095cd866a1f724cd0905054ab9d5991778ba50/sentencepiece-0.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:201a8e0f55501a76e08dbf2c54bc45f4642b379271e89c667d517bfbc2191f2a", size = 1347267, upload-time = "2026-07-12T08:38:44.389Z" }, + { url = "https://files.pythonhosted.org/packages/32/4f/31c1073314ad94466bca37d29581761d70110237ee3d46b0efece59a8c1e/sentencepiece-0.2.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8eed98514bffe5ecac37f493f91869c351fbb05629328bfdbc08502c6c094dc0", size = 1324980, upload-time = "2026-07-12T08:38:46.304Z" }, + { url = "https://files.pythonhosted.org/packages/59/b4/a0356fa04d6a14337a6e0e443556785a0422c53ec58baae6b9568120eb0f/sentencepiece-0.2.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64b656f025355cf8c51abe9fbe3848540756c6d7ca5e6791b1afa664bc24c7cb", size = 1397593, upload-time = "2026-07-12T08:38:48.302Z" }, + { url = "https://files.pythonhosted.org/packages/09/fa/d2d6369257fd2f0de616b1c7110b73fab409ef61b14f1b9e0010ed325914/sentencepiece-0.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:74f0ee601047c0c12a783088b51be4e6214a62ecd9e02278c477433cd16e0ed9", size = 1247987, upload-time = "2026-07-12T08:38:50.15Z" }, + { url = "https://files.pythonhosted.org/packages/17/ee/2bb594da6fd95e32f29057f1aa7fa996701b8980090923c2d8711fdc0a24/sentencepiece-0.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:b23fe17779834d3c27aaf2edac9486d04cca1a7deb8f5facda35150ac6263a91", size = 1187250, upload-time = "2026-07-12T08:38:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/58/9c/dfc82846460e7a712310f5613f23d8b553cabb4e2e648663c11d8382af56/sentencepiece-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:72b7825b331b1b7e7c45be2e674b3e3c65af608fa376bad2d851b20aaf0cdc78", size = 2223080, upload-time = "2026-07-12T08:38:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4e/3ff12cebe6d31662d9ceeabfb282de20bd0d6098fa282b4a3b8305abc7e8/sentencepiece-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d795c4ac689a57f9d4ba2288126ec7901d389ad5827d2f8b8533c883974fe563", size = 1458511, upload-time = "2026-07-12T08:38:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/59/5a/16d51d05360be4cee3ebfe4837c184054c4eed16cabaeb3b039524e9a000/sentencepiece-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ab3f1ae98970b5590e2209341522718900ba19bcc2c207ffaa6bd417ad960c5", size = 1361138, upload-time = "2026-07-12T08:38:58.808Z" }, + { url = "https://files.pythonhosted.org/packages/0f/af/c30ee2a9f99d51db9844acaa8fa0b611a97c2fa7116646fa43db3300b187/sentencepiece-0.2.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec27c152a1f1b24bc9168b55a5880f3c16e2334e697da6f55a1046a22405a3d", size = 1328625, upload-time = "2026-07-12T08:39:00.849Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1a/4c6b39d03f5ba8439509adbd5a23c9538088a3cb679e7a47b911e8442bc6/sentencepiece-0.2.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59d6588712101ccfcae9b03692be3aaae1514c2078666d7b05f15ba3a702e41b", size = 1398595, upload-time = "2026-07-12T08:39:02.86Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bc/9eedddcec1fd57bc70200fa3ebf792d18fa63527a5369581cd416c81f97f/sentencepiece-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:89625fb43765cccaa1443b9adb61f283e5fe4cb1536728205d06bada730caa53", size = 1259346, upload-time = "2026-07-12T08:39:04.559Z" }, + { url = "https://files.pythonhosted.org/packages/41/15/7e74c8533848866ff560b29f7d8719921b76c4ec7149592d6d28e0deee75/sentencepiece-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:4f0603267cd15b92b68c2c0e852a441507614b70dc7773659baa6b8c214a91fd", size = 1196596, upload-time = "2026-07-12T08:39:06.454Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/f5df63edb6bcb46c1343cfa5d9192d73a4eb61af2e800d9402efff387523/sentencepiece-0.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c62bd361cec1f5b556eb8210264ecfff37486cd990c3386cc00310f26c54090a", size = 2190240, upload-time = "2026-07-12T08:39:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/52/0a/095d183b453b2a2e20b016829029c58eca90adc1c9911113e5d26fff45ed/sentencepiece-0.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:46ba07b543add034de0ff47ac5f907e9a06682f91d85121a972764628933be6b", size = 1442220, upload-time = "2026-07-12T08:39:09.91Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/823954c9c90e74eba09fb96752dc37a5555df00d69866cb9406d1725dc7e/sentencepiece-0.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79bac5a251f23a7341e28fda9ce0d5319edf45328239ce037c0682936f137906", size = 1348056, upload-time = "2026-07-12T08:39:11.744Z" }, + { url = "https://files.pythonhosted.org/packages/10/ca/1b6c251321901cbf8a2d2e48b8b70eb82a449011b766af52a228d0a90b6b/sentencepiece-0.2.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1402d8ee36f0d851cea8eee4dbb85fea14643b7503cf4d00d102eec0fe3ca719", size = 1325463, upload-time = "2026-07-12T08:39:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/24/b3/718847349da7b25c8220ed86d85b89080af94740b2d87a59198104ae5c51/sentencepiece-0.2.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d44b20234905ff022b7d535f79d1f823ad7670c9851cc4f03cdc34787cdb3ab", size = 1398138, upload-time = "2026-07-12T08:39:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/33/fe/4906f12c458274edd96387e4baaad7c6f064a2b7c11a1cc2401c8a7bd483/sentencepiece-0.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:63250cfab8b80a1ef82a614eb2b3cadfec2c405f870cedc139d08e2f063eb708", size = 1356144, upload-time = "2026-07-12T08:39:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/22f89b6542aba400b0007cf0b1697cc3f99be8fb682fdb4c05eec450e33f/sentencepiece-0.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:65d84ec36888de4a848eee5f910e67fbc79b064685ef1e10a502e14520ead9c9", size = 1294351, upload-time = "2026-07-12T08:39:18.967Z" }, + { url = "https://files.pythonhosted.org/packages/84/c4/7afe8c2315b76e46818851a057e50a378a0382aa00b970a1fa444181b6f6/sentencepiece-0.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d254c98ca6387655400b3959c33c83efd807f5edeb608e3aca45800ceaa77151", size = 2223281, upload-time = "2026-07-12T08:39:20.978Z" }, + { url = "https://files.pythonhosted.org/packages/98/42/fb678e472c554ef086be6375d20060ca610a2c4218854d4c091001fc6f91/sentencepiece-0.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3fd9ce2ab4460c713cfdeb4aca693ca6732a11538e05fb332d5af42e3d7fde25", size = 1458779, upload-time = "2026-07-12T08:39:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/78/52/ffe402b13bce1889228a98dc6cd86ae8afac1112362236be3468be784441/sentencepiece-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7fc14c1585139fa6b68775e616a6b90cf622ebf219f9558c0aeaf5d253ee6c9b", size = 1361736, upload-time = "2026-07-12T08:39:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/78/4a/2288f60e7283583ec0a0f16e72f9c8e68557d7e7a4b585d2cda4f9f47e64/sentencepiece-0.2.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df88b0c34f2fa909d322f7b06b1398e1e81af4b2f42a7b8e3556f928b25d1811", size = 1328155, upload-time = "2026-07-12T08:39:26.422Z" }, + { url = "https://files.pythonhosted.org/packages/26/31/5dd6882ebe899f741a5cfe40ff56c6efc06bc26ee287abdb723b671f409c/sentencepiece-0.2.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f5851441ab1ef8634963a5100b733a8bbeefe623e0c5c005b1f1f3880e574cf", size = 1398307, upload-time = "2026-07-12T08:39:28.637Z" }, + { url = "https://files.pythonhosted.org/packages/da/05/7d7780fa63f4b8c1821953b916e25f89ae8f14d4da6ba91e10f6d06dc2b4/sentencepiece-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:046b15ea22d8042e2e173561d464ec3b64a9c2081324df70ebce7bf7ebb3e497", size = 1367133, upload-time = "2026-07-12T08:39:30.546Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/70007fef3f818c688de4a730f98024a671599ab67f20270f8efb03d69dcc/sentencepiece-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fa9f5ef0e2a82233dd0b8b32ea3f5710e0c44afbc07ed3620219f32601e56090", size = 1302760, upload-time = "2026-07-12T08:39:32.457Z" }, +] + [[package]] name = "setuptools" version = "83.0.0" @@ -1767,6 +3065,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1810,6 +3117,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "tokenizers" version = "0.22.2" @@ -1951,6 +3267,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -2466,6 +3791,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/57/5c6e0908a47f61dca96d01c8ee6fce01ed1050611eb779083ba8758fed81/xxhash-3.8.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:345b07b78e2bf583d71682aa34ae5b5fab575f7a1cb31e10263ebbc6f89f8c42", size = 32869, upload-time = "2026-07-06T10:49:55.972Z" }, ] +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/db/3cb5df059756a45761cc3dee8fd25ec82b83a6585ea3542b969fda850f99/yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3", size = 135043, upload-time = "2026-07-20T02:04:52.39Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/767d6bd5a03db63bc467df2fb56d6fafeae9667d74aea92cd6af399f828b/yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a", size = 96942, upload-time = "2026-07-20T02:04:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/ce/97/10b939c44d7b28d1dbc389cfc7012306d1ea8dba01eaef44b39fffaee52a/yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840", size = 97046, upload-time = "2026-07-20T02:04:56.638Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/b410dbe39b6255c55fb2a2bcee96eb844d0789235ddc381a889a90dc72d6/yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966", size = 110512, upload-time = "2026-07-20T02:04:58.955Z" }, + { url = "https://files.pythonhosted.org/packages/83/c7/da591971f78a5617e1f21f5699858ebccd836fe181a6493788ffc91ba69b/yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723", size = 102454, upload-time = "2026-07-20T02:05:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8e/73b0ed4de47289a78a96045d76d1cfe5e41848bf0da59ce25b2ec87ee05d/yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb", size = 117617, upload-time = "2026-07-20T02:05:02.325Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/b744747bc4f57a8d55bd744df463457524583e1e9f7538b5ace0346ab92e/yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780", size = 116135, upload-time = "2026-07-20T02:05:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/66/ca/95aa4d0e5b7ea4f20e4d577c42d001ed9df207569fdb063cc5ed4ebb496b/yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e", size = 111935, upload-time = "2026-07-20T02:05:05.738Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/d2ad8d6b147832d177a4e720ba1962fe686eb0913b74503b3eca094b8bba/yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2", size = 110010, upload-time = "2026-07-20T02:05:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/50/18/eb335e4120903903f4865041355ae46256a2406eb2865bc24827f4f27b61/yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58", size = 110058, upload-time = "2026-07-20T02:05:09.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/70/97353add32c62ad6f206d948ac5a5ee84398225e534dc6ed6433d1b335b6/yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61", size = 103308, upload-time = "2026-07-20T02:05:11.31Z" }, + { url = "https://files.pythonhosted.org/packages/68/39/5e7398d4b6f6b3c9062823ebc60802df5b272e3fe9e788f9734c6ee46c85/yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6", size = 116898, upload-time = "2026-07-20T02:05:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/09e52f2239e8b96357eccca05915382e4ba5405ebfb623b6036040d99654/yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f", size = 109400, upload-time = "2026-07-20T02:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/e94133d4c2d1a14d2384310bf3e79d9cf32c9d1eae1c6f034fb80d098fa1/yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077", size = 115934, upload-time = "2026-07-20T02:05:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/34955ed967b976fc38edcbb6d538dee79dbda4cb7fc7f72a0907a7c78e0f/yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd", size = 112178, upload-time = "2026-07-20T02:05:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/f5/46/d7bd3a8859d47dcfaffd7127af7076032a7da278a9a02e17b5f37bfb6712/yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25", size = 97544, upload-time = "2026-07-20T02:05:21.523Z" }, + { url = "https://files.pythonhosted.org/packages/01/69/c1bfd21e32c638974ea2c542a0b8c53ef1fa9eff336020f5d014f9503ff2/yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a", size = 93359, upload-time = "2026-07-20T02:05:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] + [[package]] name = "zstandard" version = "0.25.0" From dd8d2cd556bd1932722ee851c20f4478a29610be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A0=95?= Date: Mon, 10 Aug 2026 17:56:54 +0900 Subject: [PATCH 05/21] =?UTF-8?q?docs(language):=20=EC=B5=9C=EC=8B=A0=20de?= =?UTF-8?q?velop=20=EB=B0=8F=20=EC=9A=B4=EC=98=81=20Qdrant=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../T13-RUNTIME-COMPOSITION-EVIDENCE.md | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md b/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md index 69ac8a1..5629661 100644 --- a/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md +++ b/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md @@ -5,12 +5,12 @@ evidence_version: 1 task: issue-24-runtime-composition branch: feat/language-assistant-runtime-composition worktree: /Users/parktaejung/Desktop/workspace/ai/.worktrees/language-assistant-runtime-composition -base_sha: f7058c2ece93e2b3723a715780e6bac5adb3eae1 -implementation_commit: 91e08af (runtime/Ollama); this commit (Qdrant/BGE/Docker) +base_sha: 8837c5efcf1f161442e0adab8584488e0a656c0f +implementation_commit: 177e695 (runtime/Ollama); 7c56654 (Qdrant/BGE/Docker); this commit (post-rebase evidence) live_ollama_qdrant: success-with-easy-korean-fallback ollama_model: gemma4:26b-mlx ollama_structured_output: success -qdrant_endpoint: http://localhost:16333 (isolated compose.test.yml) +qdrant_endpoint: fowoco-qdrant:6333 (production volume; temporary localhost:26333 proxy removed after verification) qdrant_retrieval: success ``` @@ -25,8 +25,9 @@ qdrant_retrieval: success | C05 | Ollama/Qdrant live 호출 없이도 offline baseline을 재현할 수 있고, secret 파일 변경이 없다. | test commands below; no `.env` or secret file is in the change set | | C06 | `provider=ollama`은 native `/api/chat` adapter를 사용하고 thinking을 끄며, 실제 모델의 코드펜스 JSON을 정규화해 typed output으로 검증한다. | `test_ollama_adapter_sends_native_schema_contract`, `test_ollama_adapter_disables_thinking_for_structured_generation`, `test_ollama_adapter_parses_single_json_code_fence`, live Ollama/API result below | | C07 | 유효한 Qdrant 설정은 production `HybridEpsRetriever`를 조립하고, 고정 index contract를 통과한 collection만 검색한다. | `test_factory_selects_hybrid_retriever_when_qdrant_is_configured`, `test_real_store_mock_create_and_verify`, live retrieval result below | -| C08 | 실제 BGE-M3/Qdrant 검색은 5개 reference를 반환하며 retrieval fallback/warning이 없다. | isolated Qdrant/BGE live result below | -| C09 | production Docker image는 `language-retrieval` extra를 설치하고 앱과 retrieval 의존성을 import할 수 있다. | `docker compose build ai` exit `0`; image import smoke result below | +| C08 | production Qdrant volume에 실제 BGE-M3 index를 생성하고, 검색에서 5개 reference를 반환하며 retrieval fallback/warning이 없다. | production Qdrant/BGE live result below | +| C09 | production Docker image는 `language-retrieval` extra를 포함해 빌드된다. | `docker compose build ai` exit `0`; image metadata below | +| C10 | feature HEAD는 검수 시점의 최신 `origin/develop`을 포함한다. | `git merge-base --is-ancestor origin/develop HEAD` exit `0`; base `8837c5e` | ## Contract decision @@ -96,7 +97,7 @@ PYTHONPATH=. /opt/homebrew/bin/uv run --frozen \ ``` - Exit code: `0` -- Result: `573 passed, 1 skipped` +- Result: `576 passed, 1 skipped, 1164 warnings in 2.18s` ### Static checks @@ -183,11 +184,12 @@ curl http://localhost:11434/v1/models 기존 `wget` healthcheck는 Qdrant 이미지에 실행 파일이 없어 false `unhealthy`를 만들었다. 이미지에 실제 존재하는 `/bin/bash`와 `/dev/tcp`로 `/readyz`를 검사하도록 production/test Compose를 수정했다. - production `fowoco-qdrant`: `healthy` -- isolated `fowoco-qdrant-test`: `healthy` -- `http://localhost:16333/readyz`: HTTP `200`, `all shards are ready` +- isolated `fowoco-qdrant-test`: 이전 격리 검증에서 `healthy`; OrbStack 재시작 후 중지 상태 +- production Qdrant 검증용 임시 proxy `localhost:26333`: 검증 후 제거 +- host `6333`, `26333`: 모두 비공개/연결 거부 상태 - production Compose의 host port 비공개 계약 유지 -### Actual BGE-M3 indexing and Qdrant retrieval +### Actual BGE-M3 indexing and production Qdrant retrieval - model: `BAAI/bge-m3@5617a9f61b028005a4858fdac845db406aefb181` - source: `data/eps_language_db.json` @@ -199,6 +201,9 @@ curl http://localhost:11434/v1/models - sparse vector: `korean_sparse` - dataset/model/index provenance verification: 성공 - full indexing CLI idempotent rerun: exit `0`, `17,902` points +- production collection status: `green`, optimizer status: `ok` +- production alias: `eps_language_phrases_active` → `eps_language_phrases_29106c33d43c_5617a9f61b02` +- provenance payload indexes: 각 `17,902` points - actual `HybridEpsRetriever`: contexts `5`, fallback `false`, warnings `[]` live 인덱싱 중 qdrant-client 1.19 compatibility 오류 두 건을 재현하고 수정했다. @@ -214,9 +219,10 @@ live 인덱싱 중 qdrant-client 1.19 compatibility 오류 두 건을 재현하 - adapter 수정: native Ollama 요청에 `think: false` 명시 - 실제 API 검증 설정: 테스트 프로세스에만 `FOWOCO_LLM_TIMEOUT_SECONDS=180` 주입; `.env`와 전역 Provider 설정은 변경하지 않음 - HTTP status: `200` -- elapsed: `94.56`초 +- post-rebase production-Qdrant run elapsed: `65.51`초 - retrieval dataset version: `sha256:29106c33d43ccdd8453623ac1a0af44e0201d7c7cc1cc68c3fb438e0ccc61c6d` - retrieval reference count: `5` +- retrieval reference IDs: `ef4b8686-5a53-5133-9ca2-df615070af86`, `b9f625d6-4bcd-5758-85df-3f700ad8e25b`, `497d29fd-ea15-569e-9419-f4bc0dd87af0`, `8be7ab57-92d5-5148-9e31-00b21f8a37c1`, `fa19ac87-d641-5da7-ba60-4c85171ea8ac` - retrieval fallback: `false` - `RETRIEVAL_UNAVAILABLE`: 발생하지 않음 - translation status: `success` @@ -231,20 +237,21 @@ live 인덱싱 중 qdrant-client 1.19 compatibility 오류 두 건을 재현하 - command: `docker compose build ai` - exit: `0` - image: `fowoco-ai:latest` -- image id: `sha256:81de153f32fdcb7222af1281352ef4759c7c91a82c1f78c55c7f481e6a86b291` +- application platform manifest: `sha256:08af20ea1794e4eadf53e75158e6d7dd92b4edec81ea2113d26b4e403b54f319` - architecture: `arm64` -- image size: `3,506,302,676` bytes +- image size: `3,506,131,198` bytes - installed retrieval packages: `qdrant-client==1.19.0`, `FlagEmbedding==1.4.0`, `torch==2.13.0` -- production execution path smoke: `docker run --rm fowoco-ai:latest uv run python -c ...` → `FastAPI` +- BuildKit attestation 때문에 `latest` manifest-list digest는 빌드마다 달라질 수 있어 재현성 기준으로 사용하지 않음 +- latest image one-shot smoke: Python 시작이 두 차례 장시간 정지해 중단했으며, OrbStack 재시작 후 Qdrant 데이터 영속성을 재확인함 - 주의: Linux Torch가 CUDA 계열 wheel을 포함해 이미지가 3.51 GB다. 빌드는 성공했지만 이미지 경량화는 별도 최적화 대상이다. ## Not yet verified -- production Qdrant volume indexing; live data는 격리된 test volume에 생성함 - BGE reranker 연결; 현재 production composition은 cross-query RRF fallback을 사용함 - actual OpenAI API structured-output compatibility +- 최신 production image의 Python one-shot 실행 정지 원인; image build 자체는 성공함 -현재 production composition은 Qdrant URL이 없으면 typed degraded fallback을 사용하고, 유효한 URL에서는 lazy BGE-M3 backend와 `HybridEpsRetriever`를 조립한다. 실제 BGE-M3/Qdrant indexing·retrieval과 Ollama 결합 API 호출은 검증됐으며, reranker와 실제 OpenAI API 호환성은 주장하지 않는다. +현재 production composition은 Qdrant URL이 없으면 typed degraded fallback을 사용하고, 유효한 URL에서는 lazy BGE-M3 backend와 `HybridEpsRetriever`를 조립한다. production Qdrant volume의 실제 BGE-M3 indexing·retrieval과 Ollama 결합 API 호출은 검증됐으며, reranker·실제 OpenAI API 호환성·최신 image의 one-shot Python 실행 성공은 주장하지 않는다. ## Known unrelated environment failures From 1d3fcd4132eda44a2b8a68a5447f811716f2bdd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A0=95?= Date: Mon, 10 Aug 2026 22:59:49 +0900 Subject: [PATCH 06/21] =?UTF-8?q?docs(language):=20reranker=20Docker=20?= =?UTF-8?q?=EB=82=B4=EC=9E=A5=20=EC=84=A4=EA=B3=84=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ssistant-reranker-docker-runtime-design.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/language-assistant/engineering/specs/2026-08-10-language-assistant-reranker-docker-runtime-design.md diff --git a/docs/language-assistant/engineering/specs/2026-08-10-language-assistant-reranker-docker-runtime-design.md b/docs/language-assistant/engineering/specs/2026-08-10-language-assistant-reranker-docker-runtime-design.md new file mode 100644 index 0000000..58ea01c --- /dev/null +++ b/docs/language-assistant/engineering/specs/2026-08-10-language-assistant-reranker-docker-runtime-design.md @@ -0,0 +1,115 @@ +# Language Assistant Reranker·Docker Runtime Design + +## 상태 + +- 날짜: 2026-08-10 +- 브랜치: `feat/language-assistant-runtime-composition` +- 기준: `dd8d2cd556bd1932722ee851c20f4478a29610be` +- 결정: BGE-M3 encoder와 BGE reranker의 고정 revision 가중치를 production Docker image에 포함한다. + +## 목적 + +1. 기존 `FlagEmbeddingReranker`를 production `HybridEpsRetriever`에 연결한다. +2. 잘못 고정된 reranker revision을 Hugging Face에서 실제 존재하는 revision으로 통일한다. +3. Docker image 하나만으로 encoder·reranker 모델을 사용할 수 있게 한다. +4. 이전 Docker one-shot 정지가 코드 결함인지 런타임 일시 장애인지 실제 기동으로 판정한다. + +OpenAI API 실호출과 provider 변경은 이 작업에서 제외한다. + +## 결정과 이유 + +모델용 named volume과 별도 초기화 서비스를 두지 않는다. 대신 Docker build 중 두 모델을 고정 revision으로 내려받아 image에 포함한다. 이미지가 수 GB 커지고 build가 Hugging Face 네트워크에 의존하지만, 배포 시 추가 초기화 절차가 없어 운영 설명과 재현 절차가 단순해진다. + +요청 처리 중 모델 다운로드는 금지한다. build가 모델 다운로드에 실패하면 image build도 실패해야 한다. + +## 구성 + +### Revision 단일화 + +`app/agents/language/retrieval/manifest.py`가 다음 값을 단일 소스로 제공한다. + +- encoder: `BAAI/bge-m3@5617a9f61b028005a4858fdac845db406aefb181` +- reranker: `BAAI/bge-reranker-v2-m3@953dc6f6f85a1b2dbfca4c34a2796e7dde08d41e` + +다운로드 스크립트, production composition, reranker adapter와 테스트가 같은 상수를 사용한다. 현재 다운로드 스크립트의 존재하지 않는 `953dc6f6f85ac1e88eb36f5f9ce67a74a6edbc22`는 제거한다. + +### Production composition + +`_build_retriever()`는 기존 encoder와 Qdrant store에 다음 reranker를 추가한다. + +```text +/bge-reranker-v2-m3/ + → FlagEmbeddingReranker + → HybridEpsRetriever(reranker=...) +``` + +모델은 service 생성이나 app import 시 로드하지 않는다. 첫 retrieval에서 lazy load한다. 모델 경로 누락이나 추론 실패는 기존 계약대로 cross-query RRF 상위 5개를 사용하고 `degraded_components=["reranker"]`를 반환한다. + +CPU 기반 Docker 실행을 기본으로 하므로 reranker는 FP16을 강제하지 않는다. 별도 GPU 최적화와 score threshold는 추가하지 않는다. + +### Docker image + +Dockerfile은 application과 다운로드 스크립트를 복사한 뒤 `/app/.venv/bin/python`으로 고정-revision 다운로드를 실행한다. + +```text +/opt/fowoco/language-models/ + bge-m3// + bge-reranker-v2-m3// +``` + +image의 `FOWOCO_MODEL_CACHE_DIR` 기본값은 `/opt/fowoco/language-models`다. Compose의 기존 `/data/model-cache` override도 같은 baked model 경로로 바꾼다. Compose에는 모델 초기화 서비스나 모델 volume을 추가하지 않는다. + +`.dockerignore`는 전체 `scripts` 제외를 `scripts/*`로 좁히고 `scripts/download_language_models.py`만 다시 포함한다. host의 모델 캐시와 나머지 스크립트는 build context에 넣지 않는다. + +### Docker 정지 판정 + +현재 최신 image에서 다음 경로는 모두 정상 종료했다. + +- `/bin/true` +- `/app/.venv/bin/python --version` +- `uv run python --version` +- `uv run python -c 'from app.main import create_app; ...'` + +따라서 선제 코드 수정은 하지 않는다. 모델 포함 image를 새로 build한 뒤 one-shot import와 `docker compose up -d ai` healthcheck가 통과하면 이전 정지는 OrbStack 런타임의 일시 상태로 기록한다. 같은 경계에서 다시 정지할 때만 재현 테스트를 만든 후 진입점을 수정한다. + +## 테스트와 검증 + +자동 테스트는 외부 네트워크를 사용하지 않는다. + +1. reranker repo/revision 단일 상수와 다운로드 spec 일치 +2. 유효한 Qdrant 설정에서 production composition이 `FlagEmbeddingReranker`를 주입 +3. reranker 모델 누락·실패 시 기존 RRF fallback/degradation 유지 +4. Dockerfile이 두 모델을 image build 중 다운로드하고 image 내부 경로를 설정 +5. 기존 Language Assistant 및 전체 회귀 테스트 통과 + +실검증은 다음을 별도 Evidence로 남긴다. + +1. 고정 revision 두 모델의 Docker build-time 다운로드 성공 +2. 실제 BGE-M3 + production Qdrant + reranker 검색에서 context 5개, `selected_by="reranker"`, reranker degradation 없음 +3. production Docker image one-shot import 성공 +4. `docker compose up -d ai` 후 AI container healthcheck 성공 +5. image 크기와 build 시간 기록 + +## 변경 예상 파일 + +- `app/agents/language/retrieval/manifest.py` +- `app/agents/language/retrieval/reranker.py` +- `app/agents/language/composition.py` +- `scripts/download_language_models.py` +- `Dockerfile` +- `.dockerignore` +- `compose.yml` +- 관련 Language Assistant·Docker 테스트 +- `T13-RUNTIME-COMPOSITION-EVIDENCE.md` + +## 비범위 + +- 실제 OpenAI API 검증 또는 provider 변경 +- reranker 품질 평가, score threshold, PR curve +- GPU/CUDA 최적화와 다중 worker 모델 공유 +- 모델 volume·초기화 서비스·request-time 다운로드 +- GitHub 이슈·댓글 수정 + +## 보안 + +`.env`, API key, token, secret을 만들거나 커밋하지 않는다. 모델은 공개 Hugging Face repository의 고정 revision만 사용한다. From 047abe279658a5204aad04cc8e34fa5b33145572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A0=95?= Date: Mon, 10 Aug 2026 23:03:30 +0900 Subject: [PATCH 07/21] =?UTF-8?q?docs(language):=20reranker=20Docker=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...guage-assistant-reranker-docker-runtime.md | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 docs/language-assistant/engineering/plans/2026-08-10-language-assistant-reranker-docker-runtime.md diff --git a/docs/language-assistant/engineering/plans/2026-08-10-language-assistant-reranker-docker-runtime.md b/docs/language-assistant/engineering/plans/2026-08-10-language-assistant-reranker-docker-runtime.md new file mode 100644 index 0000000..66e0d6e --- /dev/null +++ b/docs/language-assistant/engineering/plans/2026-08-10-language-assistant-reranker-docker-runtime.md @@ -0,0 +1,336 @@ +# Language Assistant Reranker Docker Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 고정 revision BGE-M3와 BGE reranker를 production Docker image에 포함하고, production retrieval composition에서 실제 reranker를 사용한다. + +**Architecture:** 모델 repo/revision은 retrieval manifest를 단일 소스로 사용한다. Docker build가 두 모델을 `/opt/fowoco/language-models`에 내려받고, production composition은 같은 경로 규칙으로 lazy encoder와 reranker를 조립한다. 외부 모델·Qdrant 실검증은 자동 테스트와 분리해 Evidence에 기록한다. + +**Tech Stack:** Python 3.12, FastAPI, Qdrant 1.18.3, qdrant-client 1.19.x, FlagEmbedding 1.4.x, Hugging Face Hub, pytest, Ruff, Docker Compose + +## Global Constraints + +- OpenAI API·provider 설정은 변경하거나 호출하지 않는다. +- Encoder revision은 `5617a9f61b028005a4858fdac845db406aefb181`이다. +- Reranker revision은 `953dc6f6f85a1b2dbfca4c34a2796e7dde08d41e`이다. +- 모델은 Docker build 중 다운로드하고 request 처리 중 다운로드하지 않는다. +- 모델 volume이나 별도 init service를 추가하지 않는다. +- 모델 누락·reranker 실패 시 기존 cross-query RRF fallback 계약을 유지한다. +- `.env`, API key, token, secret을 생성하거나 커밋하지 않는다. +- 기존 미추적 `docs/language-assistant/engineering/plans/2026-08-10-language-assistant-runtime-composition.md`는 수정·스테이징하지 않는다. + +--- + +### Task 1: Reranker revision 단일화 + +**Files:** +- Modify: `app/agents/language/retrieval/manifest.py` +- Modify: `scripts/download_language_models.py` +- Modify: `tests/agents/language/test_model_cache.py` + +**Interfaces:** +- Produces: `BGE_RERANKER_MODEL_REPO: str`, `BGE_RERANKER_REVISION: str` +- Consumes: 기존 `BGE_M3_MODEL_REPO`, `BGE_M3_REVISION` + +- [ ] **Step 1: 잘못된 revision을 잡는 실패 테스트 작성** + +```python +def test_manifest_constants_defined() -> None: + from app.agents.language.retrieval.manifest import ( + BGE_M3_REVISION, + BGE_RERANKER_MODEL_REPO, + BGE_RERANKER_REVISION, + ) + from scripts.download_language_models import MODEL_SPECS + + assert BGE_M3_REVISION == "5617a9f61b028005a4858fdac845db406aefb181" + assert BGE_RERANKER_MODEL_REPO == "BAAI/bge-reranker-v2-m3" + assert BGE_RERANKER_REVISION == "953dc6f6f85a1b2dbfca4c34a2796e7dde08d41e" + assert MODEL_SPECS[1]["revision"] == BGE_RERANKER_REVISION +``` + +- [ ] **Step 2: RED 확인** + +Run: + +```bash +PYTHONPATH=. .venv/bin/python -m pytest \ + tests/agents/language/test_model_cache.py::TestModelManifest::test_manifest_constants_defined +``` + +Expected: manifest에 reranker 상수가 없어 FAIL. + +- [ ] **Step 3: manifest 상수를 추가하고 스크립트의 중복 상수 제거** + +```python +BGE_RERANKER_MODEL_REPO = "BAAI/bge-reranker-v2-m3" +BGE_RERANKER_REVISION = "953dc6f6f85a1b2dbfca4c34a2796e7dde08d41e" +``` + +`MODEL_SPECS`는 두 값을 `manifest.py`에서 import해 사용한다. + +- [ ] **Step 4: GREEN 확인** + +```bash +PYTHONPATH=. .venv/bin/python -m pytest tests/agents/language/test_model_cache.py +.venv/bin/ruff check app/agents/language/retrieval/manifest.py \ + scripts/download_language_models.py tests/agents/language/test_model_cache.py +``` + +- [ ] **Step 5: 커밋** + +```bash +git add app/agents/language/retrieval/manifest.py \ + scripts/download_language_models.py tests/agents/language/test_model_cache.py +git commit -m "fix(language): reranker 모델 revision 단일화" +``` + +### Task 2: Production reranker composition 연결 + +**Files:** +- Modify: `app/agents/language/retrieval/reranker.py` +- Modify: `app/agents/language/composition.py` +- Modify: `tests/integration/language/test_runtime_composition.py` + +**Interfaces:** +- Consumes: Task 1의 `BGE_RERANKER_REVISION` +- Produces: `HybridEpsRetriever.reranker: FlagEmbeddingReranker` + +- [ ] **Step 1: production composition 실패 테스트 작성** + +```python +def test_factory_wires_fixed_revision_reranker_when_qdrant_is_configured( + tmp_path: Path, +) -> None: + from app.agents.language.composition import _build_production_ports + from app.agents.language.retrieval.manifest import BGE_RERANKER_REVISION + from app.agents.language.retrieval.reranker import FlagEmbeddingReranker + + _, retriever, _, _, _ = _build_production_ports( + Settings( + llm_provider="ollama", + llm_base_url="http://localhost:11434/v1", + llm_model="gemma4:26b-mlx", + qdrant_url="http://qdrant:6333", + model_cache_dir=tmp_path, + ) + ) + + assert isinstance(retriever, HybridEpsRetriever) + assert isinstance(retriever.reranker, FlagEmbeddingReranker) + assert retriever.reranker.model_path == str( + tmp_path / "bge-reranker-v2-m3" / BGE_RERANKER_REVISION + ) + assert retriever.reranker.use_fp16 is False +``` + +- [ ] **Step 2: RED 확인** + +```bash +PYTHONPATH=. .venv/bin/python -m pytest \ + tests/integration/language/test_runtime_composition.py::test_factory_wires_fixed_revision_reranker_when_qdrant_is_configured +``` + +Expected: `retriever.reranker is None`으로 FAIL. + +- [ ] **Step 3: 기존 adapter를 최소 연결** + +`FlagEmbeddingReranker`에 `use_fp16: bool = False`를 저장하고 model 생성에 전달한다. `_build_retriever()`는 다음 경로로 adapter를 만든다. + +```python +reranker_path = ( + settings.model_cache_dir + / "bge-reranker-v2-m3" + / BGE_RERANKER_REVISION +) +reranker = FlagEmbeddingReranker( + model_path=str(reranker_path), + expected_revision=BGE_RERANKER_REVISION, + use_fp16=False, +) +``` + +`HybridEpsRetriever(..., reranker=reranker, ...)`로 주입한다. constructor에서 모델을 로드하거나 네트워크를 호출하지 않는다. + +- [ ] **Step 4: GREEN과 fallback 회귀 확인** + +```bash +PYTHONPATH=. .venv/bin/python -m pytest \ + tests/integration/language/test_runtime_composition.py \ + tests/agents/language/test_retrieval_service.py +.venv/bin/ruff check app/agents/language/composition.py \ + app/agents/language/retrieval/reranker.py \ + tests/integration/language/test_runtime_composition.py +``` + +- [ ] **Step 5: 커밋** + +```bash +git add app/agents/language/composition.py \ + app/agents/language/retrieval/reranker.py \ + tests/integration/language/test_runtime_composition.py +git commit -m "feat(language): production reranker 검색 경로 연결" +``` + +### Task 3: 모델을 production Docker image에 포함 + +**Files:** +- Modify: `Dockerfile` +- Modify: `.dockerignore` +- Modify: `compose.yml` +- Modify: `tests/integration/language/test_compose_config.py` + +**Interfaces:** +- Consumes: `scripts/download_language_models.py --cache-dir PATH` +- Produces: image model root `/opt/fowoco/language-models` + +- [ ] **Step 1: Docker 계약 실패 테스트 작성** + +```python +def test_production_image_bakes_language_models() -> None: + dockerfile = (ROOT / "Dockerfile").read_text() + dockerignore = (ROOT / ".dockerignore").read_text().splitlines() + + assert "COPY scripts/download_language_models.py ./scripts/" in dockerfile + assert "/app/.venv/bin/python scripts/download_language_models.py" in dockerfile + assert "--cache-dir /opt/fowoco/language-models" in dockerfile + assert "FOWOCO_MODEL_CACHE_DIR=/opt/fowoco/language-models" in dockerfile + assert "scripts/*" in dockerignore + assert "!scripts/download_language_models.py" in dockerignore + assert "scripts" not in dockerignore + + +def test_ai_service_uses_baked_model_path() -> None: + import yaml + + data = yaml.safe_load((ROOT / "compose.yml").read_text()) + assert data["services"]["ai"]["environment"]["FOWOCO_MODEL_CACHE_DIR"] \ + == "/opt/fowoco/language-models" +``` + +- [ ] **Step 2: RED 확인** + +```bash +PYTHONPATH=. .venv/bin/python -m pytest \ + tests/integration/language/test_compose_config.py::test_production_image_bakes_language_models \ + tests/integration/language/test_compose_config.py::test_ai_service_uses_baked_model_path +``` + +Expected: Dockerfile script copy/download와 baked path가 없어 FAIL. + +- [ ] **Step 3: Dockerfile과 Compose 최소 수정** + +`.dockerignore`에서 `scripts`를 `scripts/*`로 바꾸고 다운로드 스크립트만 re-include한다. Dockerfile은 app과 script를 복사한 뒤 다음을 실행한다. + +```dockerfile +COPY scripts/download_language_models.py ./scripts/ +RUN /app/.venv/bin/python scripts/download_language_models.py \ + --cache-dir /opt/fowoco/language-models +ENV FOWOCO_MODEL_CACHE_DIR=/opt/fowoco/language-models +``` + +Compose의 `FOWOCO_MODEL_CACHE_DIR`도 `/opt/fowoco/language-models`로 바꾼다. + +- [ ] **Step 4: GREEN과 구성 검증** + +```bash +PYTHONPATH=. .venv/bin/python -m pytest tests/integration/language/test_compose_config.py +docker compose config --quiet +.venv/bin/ruff check tests/integration/language/test_compose_config.py +git diff --check +``` + +- [ ] **Step 5: 커밋** + +```bash +git add Dockerfile .dockerignore compose.yml \ + tests/integration/language/test_compose_config.py +git commit -m "feat(language): Docker 이미지에 검색 모델 포함" +``` + +### Task 4: 실제 모델·Qdrant·Docker 검증과 Evidence + +**Files:** +- Modify: `docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md` + +**Interfaces:** +- Consumes: Task 1~3의 final HEAD, production `fowoco-qdrant`, local Ollama +- Produces: reranker live 결과와 Docker runtime evidence + +- [ ] **Step 1: 자동 회귀 검증** + +```bash +PYTHONPATH=. /opt/homebrew/bin/uv run --frozen \ + --extra dev --extra language-retrieval \ + pytest --ignore=tests/ocr/test_smoke_script.py +/opt/homebrew/bin/uv run --frozen --extra dev --extra language-retrieval ruff check \ + app/agents/language/composition.py \ + app/agents/language/retrieval/manifest.py \ + app/agents/language/retrieval/reranker.py \ + scripts/download_language_models.py \ + tests/agents/language/test_model_cache.py \ + tests/integration/language/test_runtime_composition.py \ + tests/integration/language/test_compose_config.py +/opt/homebrew/bin/uv lock --check +docker compose config --quiet +docker compose -f compose.test.yml config --quiet +git diff --check +``` + +Expected: 모두 exit `0`. + +- [ ] **Step 2: 모델 내장 image build** + +```bash +docker compose build ai +``` + +Expected: 두 고정 revision 다운로드와 image export 성공. build 시간, image 크기, platform manifest를 기록한다. + +- [ ] **Step 3: image 내부 모델과 one-shot 확인** + +```bash +docker run --rm --entrypoint /bin/sh fowoco-ai:latest -ec \ + 'test -f /opt/fowoco/language-models/bge-m3/5617a9f61b028005a4858fdac845db406aefb181/config.json && test -f /opt/fowoco/language-models/bge-reranker-v2-m3/953dc6f6f85a1b2dbfca4c34a2796e7dde08d41e/config.json' +docker run --rm fowoco-ai:latest uv run python -c \ + 'from app.main import create_app; print(type(create_app()).__name__)' +``` + +Expected: 두 command exit `0`, second output에 `FastAPI`. + +- [ ] **Step 4: production Qdrant와 실제 reranker 검색** + +production Docker network에서 baked image의 `_build_production_ports()`를 사용해 세 `SearchQuery`를 검색한다. 결과는 다음을 모두 만족해야 한다. + +```text +len(contexts) == 5 +all(context.selected_by == "reranker" for context in contexts) +"reranker" not in degraded_components +fallback_used == false +``` + +- [ ] **Step 5: Compose AI service 기동 확인** + +```bash +docker compose up -d ai +docker inspect fowoco-ai --format '{{.State.Health.Status}}' +curl -fsS http://localhost:8000/openapi.json +docker compose stop ai +``` + +Expected: health `healthy`, OpenAPI HTTP `200`. Qdrant service와 production volume은 유지한다. + +- [ ] **Step 6: Evidence 갱신·검토** + +Evidence에 final SHA lineage, 실제 revision, reranker 결과, Docker build/runtime 결과, image 크기, build 시간, OpenAI 제외를 기록한다. 이전 one-shot 정지는 재현되지 않은 OrbStack 일시 상태로 수정한다. + +- [ ] **Step 7: Evidence 커밋** + +```bash +git add docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md +git commit -m "docs(language): reranker 및 Docker 실검증 기록" +``` + +- [ ] **Step 8: 깨끗한 detached worktree 최종 검증** + +final HEAD를 `/private/tmp`의 detached worktree에서 다시 checkout하고 Step 1의 자동 검증을 반복한다. `git status --short`가 비어 있고 `origin/develop` ancestry가 유지되는지 확인한다. From 9630b976bd9d9fbaef8288ce8cc0d36fd2f93604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A0=95?= Date: Mon, 10 Aug 2026 23:10:48 +0900 Subject: [PATCH 08/21] =?UTF-8?q?fix(language):=20reranker=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20revision=20=EB=8B=A8=EC=9D=BC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agents/language/retrieval/manifest.py | 4 ++++ scripts/download_language_models.py | 7 +++---- tests/agents/language/test_model_cache.py | 11 +++++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/agents/language/retrieval/manifest.py b/app/agents/language/retrieval/manifest.py index 976bbb8..735c9f8 100644 --- a/app/agents/language/retrieval/manifest.py +++ b/app/agents/language/retrieval/manifest.py @@ -6,6 +6,8 @@ ) BGE_M3_MODEL_REPO = "BAAI/bge-m3" BGE_M3_REVISION = "5617a9f61b028005a4858fdac845db406aefb181" +BGE_RERANKER_MODEL_REPO = "BAAI/bge-reranker-v2-m3" +BGE_RERANKER_REVISION = "953dc6f6f85a1b2dbfca4c34a2796e7dde08d41e" INDEX_CONTRACT_VERSION = "eps-language-index-v1" EPS_POINT_COUNT = 17_902 @@ -23,6 +25,8 @@ def build_expected_index_contract() -> ExpectedIndexContract: __all__ = [ "BGE_M3_MODEL_REPO", "BGE_M3_REVISION", + "BGE_RERANKER_MODEL_REPO", + "BGE_RERANKER_REVISION", "EPS_DATASET_REVISION", "EPS_POINT_COUNT", "INDEX_CONTRACT_VERSION", diff --git a/scripts/download_language_models.py b/scripts/download_language_models.py index 01c08e5..8f6572b 100644 --- a/scripts/download_language_models.py +++ b/scripts/download_language_models.py @@ -20,11 +20,10 @@ from app.agents.language.retrieval.manifest import ( BGE_M3_MODEL_REPO, BGE_M3_REVISION, + BGE_RERANKER_MODEL_REPO, + BGE_RERANKER_REVISION, ) -# 정확한 모델 리비전 — 변경 금지 (T13 계약) -BGE_RERANKER_REVISION = "953dc6f6f85ac1e88eb36f5f9ce67a74a6edbc22" - MODEL_SPECS: list[dict[str, str]] = [ { "name": "bge-m3", @@ -33,7 +32,7 @@ }, { "name": "bge-reranker-v2-m3", - "repo": "BAAI/bge-reranker-v2-m3", + "repo": BGE_RERANKER_MODEL_REPO, "revision": BGE_RERANKER_REVISION, }, ] diff --git a/tests/agents/language/test_model_cache.py b/tests/agents/language/test_model_cache.py index 9f2c153..28ca484 100644 --- a/tests/agents/language/test_model_cache.py +++ b/tests/agents/language/test_model_cache.py @@ -10,10 +10,17 @@ class TestModelManifest: """모델 캐시 매니페스트 파일 구조 검증.""" def test_manifest_constants_defined(self) -> None: - from scripts.download_language_models import BGE_M3_REVISION, BGE_RERANKER_REVISION + from app.agents.language.retrieval.manifest import ( + BGE_M3_REVISION, + BGE_RERANKER_MODEL_REPO, + BGE_RERANKER_REVISION, + ) + from scripts.download_language_models import MODEL_SPECS assert BGE_M3_REVISION == "5617a9f61b028005a4858fdac845db406aefb181" - assert BGE_RERANKER_REVISION == "953dc6f6f85ac1e88eb36f5f9ce67a74a6edbc22" + assert BGE_RERANKER_MODEL_REPO == "BAAI/bge-reranker-v2-m3" + assert BGE_RERANKER_REVISION == "953dc6f6f85a1b2dbfca4c34a2796e7dde08d41e" + assert MODEL_SPECS[1]["revision"] == BGE_RERANKER_REVISION def test_model_specs_have_required_fields(self) -> None: from scripts.download_language_models import MODEL_SPECS From 8e227211856e686f7e2cae08d26a617bd3e8af86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A0=95?= Date: Mon, 10 Aug 2026 23:14:33 +0900 Subject: [PATCH 09/21] =?UTF-8?q?feat(language):=20production=20reranker?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=EA=B2=BD=EB=A1=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agents/language/composition.py | 12 ++++++++- app/agents/language/retrieval/reranker.py | 4 ++- .../language/test_runtime_composition.py | 27 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/app/agents/language/composition.py b/app/agents/language/composition.py index 7d47acf..1a3ec8b 100644 --- a/app/agents/language/composition.py +++ b/app/agents/language/composition.py @@ -31,11 +31,13 @@ ) from app.agents.language.retrieval.manifest import ( BGE_M3_REVISION, + BGE_RERANKER_REVISION, QDRANT_COLLECTION_ALIAS, build_expected_index_contract, ) from app.agents.language.retrieval.models import RetrievalResult from app.agents.language.retrieval.qdrant_store import QdrantStore +from app.agents.language.retrieval.reranker import FlagEmbeddingReranker from app.agents.language.retrieval.service import HybridEpsRetriever from app.agents.language.service import LanguageAssistantService from app.agents.language.validation import GeneratedSemanticValidator @@ -107,6 +109,14 @@ def _build_retriever(settings: Settings) -> EpsRetriever: encoder = BgeM3Encoder( backend=FlagEmbeddingBgeM3Backend(str(model_path)), ) + reranker_path = ( + settings.model_cache_dir / "bge-reranker-v2-m3" / BGE_RERANKER_REVISION + ) + reranker = FlagEmbeddingReranker( + model_path=str(reranker_path), + expected_revision=BGE_RERANKER_REVISION, + use_fp16=False, + ) store = QdrantStore( client=client, collection_alias=QDRANT_COLLECTION_ALIAS, @@ -114,7 +124,7 @@ def _build_retriever(settings: Settings) -> EpsRetriever: return HybridEpsRetriever( encoder=encoder, store=store, - reranker=None, + reranker=reranker, expected_index_contract=build_expected_index_contract(), ) diff --git a/app/agents/language/retrieval/reranker.py b/app/agents/language/retrieval/reranker.py index 5fe7ccf..71a83e7 100644 --- a/app/agents/language/retrieval/reranker.py +++ b/app/agents/language/retrieval/reranker.py @@ -62,10 +62,12 @@ def __init__( model_path: str = "/models/bge-reranker-v2-m3", expected_revision: str = "953dc6f6f85a1b2dbfca4c34a2796e7dde08d41e", max_length: int = 256, + use_fp16: bool = False, ) -> None: self.model_path = model_path self.expected_revision = expected_revision self.max_length = max_length + self.use_fp16 = use_fp16 self._reranker_model: Any = None def _get_model(self) -> Any: @@ -76,7 +78,7 @@ def _get_model(self) -> Any: raise RuntimeError("FlagEmbedding model not available") from err self._reranker_model = FlagReranker( - self.model_path, use_fp16=True + self.model_path, use_fp16=self.use_fp16 ) return self._reranker_model diff --git a/tests/integration/language/test_runtime_composition.py b/tests/integration/language/test_runtime_composition.py index e9096ce..d229d8c 100644 --- a/tests/integration/language/test_runtime_composition.py +++ b/tests/integration/language/test_runtime_composition.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest from app.agents.language.contracts import LanguageExecutionPolicy @@ -110,6 +112,31 @@ def test_factory_selects_hybrid_retriever_when_qdrant_is_configured() -> None: assert isinstance(retriever, HybridEpsRetriever) +def test_factory_wires_fixed_revision_reranker_when_qdrant_is_configured( + tmp_path: Path, +) -> None: + from app.agents.language.composition import _build_production_ports + from app.agents.language.retrieval.manifest import BGE_RERANKER_REVISION + from app.agents.language.retrieval.reranker import FlagEmbeddingReranker + + _, retriever, _, _, _ = _build_production_ports( + Settings( + llm_provider="ollama", + llm_base_url="http://localhost:11434/v1", + llm_model="gemma4:26b-mlx", + qdrant_url="http://qdrant:6333", + model_cache_dir=tmp_path, + ) + ) + + assert isinstance(retriever, HybridEpsRetriever) + assert isinstance(retriever.reranker, FlagEmbeddingReranker) + assert retriever.reranker.model_path == str( + tmp_path / "bge-reranker-v2-m3" / BGE_RERANKER_REVISION + ) + assert retriever.reranker.use_fp16 is False + + def test_factory_rejects_missing_generation_settings() -> None: _, unavailable, build_service = _composition_types() From d43e163d998657e2a2fe3cbcbb206493b6933776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A0=95?= Date: Mon, 10 Aug 2026 23:19:51 +0900 Subject: [PATCH 10/21] =?UTF-8?q?feat(language):=20Docker=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EC=97=90=20=EA=B2=80=EC=83=89=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 5 ++-- Dockerfile | 7 +++++- compose.yml | 2 +- .../language/test_compose_config.py | 24 +++++++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index 650afa1..c100bdd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,7 +26,8 @@ tests .env.local *.pem -# 문서·스크립트 (이미지에 불필요) -scripts +# 문서·스크립트 (모델 다운로더만 이미지에 포함) +scripts/* +!scripts/download_language_models.py *.md !README.md diff --git a/Dockerfile b/Dockerfile index 5c89ab9..984a989 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ FOWOCO_HWPX_TO_HWP_ENABLED=true \ FOWOCO_HWPX_PDF_ENABLED=true \ FOWOCO_DOCUMENT_SNAPSHOT_DIR=/data/document-snapshots \ - FOWOCO_MODEL_CACHE_DIR=/data/model-cache + FOWOCO_MODEL_CACHE_DIR=/opt/fowoco/language-models COPY --from=uv /uv /usr/local/bin/uv COPY --from=rhwp /opt/rhwp/rhwp /usr/local/bin/rhwp @@ -49,6 +49,11 @@ RUN uv sync --frozen --no-dev --extra language-retrieval # 앱 패키지 복사 COPY app ./app +COPY scripts/download_language_models.py ./scripts/ + +# 고정 revision의 검색 모델을 이미지에 포함해 런타임 다운로드를 없앤다. +RUN /app/.venv/bin/python scripts/download_language_models.py \ + --cache-dir /opt/fowoco/language-models # uvicorn 기본 포트 EXPOSE 8000 diff --git a/compose.yml b/compose.yml index f7754d4..69f48ce 100644 --- a/compose.yml +++ b/compose.yml @@ -38,7 +38,7 @@ services: environment: FOWOCO_DOCUMENT_SNAPSHOT_DIR: /data/document-snapshots FOWOCO_QDRANT_URL: http://qdrant:6333 - FOWOCO_MODEL_CACHE_DIR: /data/model-cache + FOWOCO_MODEL_CACHE_DIR: /opt/fowoco/language-models volumes: - fowoco-document-data:/data depends_on: diff --git a/tests/integration/language/test_compose_config.py b/tests/integration/language/test_compose_config.py index 5bf5197..9381958 100644 --- a/tests/integration/language/test_compose_config.py +++ b/tests/integration/language/test_compose_config.py @@ -11,6 +11,30 @@ ROOT = Path(__file__).resolve().parents[3] +def test_production_image_bakes_language_models() -> None: + dockerfile = (ROOT / "Dockerfile").read_text() + dockerignore = (ROOT / ".dockerignore").read_text().splitlines() + + assert "COPY scripts/download_language_models.py ./scripts/" in dockerfile + assert "/app/.venv/bin/python scripts/download_language_models.py" in dockerfile + assert "--cache-dir /opt/fowoco/language-models" in dockerfile + assert "FOWOCO_MODEL_CACHE_DIR=/opt/fowoco/language-models" in dockerfile + assert "scripts/*" in dockerignore + assert "!scripts/download_language_models.py" in dockerignore + assert "scripts" not in dockerignore + + +def test_ai_service_uses_baked_model_path() -> None: + import yaml + + data = yaml.safe_load((ROOT / "compose.yml").read_text()) + + assert ( + data["services"]["ai"]["environment"]["FOWOCO_MODEL_CACHE_DIR"] + == "/opt/fowoco/language-models" + ) + + @pytest.mark.parametrize("compose_file", ("compose.yml", "compose.test.yml")) def test_qdrant_healthcheck_uses_available_bash_tcp_probe( compose_file: str, From 015d4d44262bbe1f8b8a9d422ccefbcc03b4c65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A0=95?= Date: Mon, 10 Aug 2026 23:39:27 +0900 Subject: [PATCH 11/21] =?UTF-8?q?docs(language):=20reranker=20Docker=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../T13-RUNTIME-COMPOSITION-EVIDENCE.md | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md b/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md index 5629661..2472f6c 100644 --- a/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md +++ b/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md @@ -6,12 +6,14 @@ task: issue-24-runtime-composition branch: feat/language-assistant-runtime-composition worktree: /Users/parktaejung/Desktop/workspace/ai/.worktrees/language-assistant-runtime-composition base_sha: 8837c5efcf1f161442e0adab8584488e0a656c0f -implementation_commit: 177e695 (runtime/Ollama); 7c56654 (Qdrant/BGE/Docker); this commit (post-rebase evidence) +implementation_commit: 177e695 (runtime/Ollama); 7c56654 (Qdrant/BGE/Docker); 8e227211 (reranker composition); d43e163 (model-baked Docker contract); this commit (Task 4 evidence) live_ollama_qdrant: success-with-easy-korean-fallback ollama_model: gemma4:26b-mlx ollama_structured_output: success qdrant_endpoint: fowoco-qdrant:6333 (production volume; temporary localhost:26333 proxy removed after verification) qdrant_retrieval: success +docker_baked_model_build: failed-before-model-download +docker_baked_model_failure: ModuleNotFoundError for app in download_language_models.py ``` ## Claims @@ -28,6 +30,7 @@ qdrant_retrieval: success | C08 | production Qdrant volume에 실제 BGE-M3 index를 생성하고, 검색에서 5개 reference를 반환하며 retrieval fallback/warning이 없다. | production Qdrant/BGE live result below | | C09 | production Docker image는 `language-retrieval` extra를 포함해 빌드된다. | `docker compose build ai` exit `0`; image metadata below | | C10 | feature HEAD는 검수 시점의 최신 `origin/develop`을 포함한다. | `git merge-base --is-ancestor origin/develop HEAD` exit `0`; base `8837c5e` | +| C11 | production retriever는 고정 revision의 BGE reranker를 lazy하게 조립하고 실패 시 기존 degraded 계약을 유지한다. | `test_factory_wires_fixed_revision_reranker_when_qdrant_is_configured`, Task 2 focused suite `23 passed`; 실제 container reranking은 아래 Task 4 build 실패로 미검증 | ## Contract decision @@ -245,13 +248,49 @@ live 인덱싱 중 qdrant-client 1.19 compatibility 오류 두 건을 재현하 - latest image one-shot smoke: Python 시작이 두 차례 장시간 정지해 중단했으며, OrbStack 재시작 후 Qdrant 데이터 영속성을 재확인함 - 주의: Linux Torch가 CUDA 계열 wheel을 포함해 이미지가 3.51 GB다. 빌드는 성공했지만 이미지 경량화는 별도 최적화 대상이다. +위 결과는 BGE-M3와 reranker를 이미지에 직접 포함하기 전 image의 기록이다. 모델 bake를 추가한 `d43e163`에서 2026-08-10에 다음 Task 4 검증을 별도로 수행했다. + +### Model-baked production Docker Task 4 attempt + +Preflight: + +- branch: `feat/language-assistant-runtime-composition` +- HEAD: `d43e163d998657e2a2fe3cbcbb206493b6933776` +- `git status --short`: 사용자 소유 untracked 계획 문서 1개만 존재 +- `fowoco-qdrant`: `running`, `healthy` +- Qdrant collection, alias, point, volume 변경: 없음 + +Build: + +```bash +docker compose build ai +``` + +- Exit code: `1` +- Python retrieval dependency 설치: 성공 (`FlagEmbedding==1.4.0`, `torch==2.13.0`, `qdrant-client==1.19.0` 포함) +- 모델 bake command 진입: 성공 +- 실패 command: `/app/.venv/bin/python scripts/download_language_models.py --cache-dir /opt/fowoco/language-models` +- 실패 원인: `ModuleNotFoundError: No module named 'app'` +- 실패 위치: `download_language_models.py`가 `app.agents.language.retrieval.manifest`를 import하는 시점 +- BGE-M3/reranker model download 시작: 하지 못함 + +Build가 model download 전에 실패했으므로 다음 항목은 성공으로 주장하지 않는다. + +- model-baked image platform manifest/size: 생성되지 않음 +- 두 모델의 `config.json` image 내부 존재: 미검증 +- 최신 image health 및 `/openapi.json`: 미검증 +- 최신 image one-shot `create_app()`: 미검증 +- 실제 Qdrant hybrid retrieval + BGE reranker JSON assert: 미검증 + +실패 후 `fowoco-qdrant`가 계속 `running`, `healthy`임을 재확인했다. AI service는 시작되지 않았으므로 stop 대상이 없었고, Qdrant는 중지·삭제·재색인하지 않았다. 외부 LLM/Ollama/OpenAI 호출과 provider 설정 변경도 수행하지 않았다. + ## Not yet verified -- BGE reranker 연결; 현재 production composition은 cross-query RRF fallback을 사용함 -- actual OpenAI API structured-output compatibility -- 최신 production image의 Python one-shot 실행 정지 원인; image build 자체는 성공함 +- model-baked production image의 실제 BGE reranker 성공 경로; build가 model download 전에 실패함 +- model-baked production image의 manifest/size, model `config.json`, health/OpenAPI, one-shot `create_app()` +- actual OpenAI API structured-output compatibility; 현재 작업 범위에서 명시적으로 제외함 -현재 production composition은 Qdrant URL이 없으면 typed degraded fallback을 사용하고, 유효한 URL에서는 lazy BGE-M3 backend와 `HybridEpsRetriever`를 조립한다. production Qdrant volume의 실제 BGE-M3 indexing·retrieval과 Ollama 결합 API 호출은 검증됐으며, reranker·실제 OpenAI API 호환성·최신 image의 one-shot Python 실행 성공은 주장하지 않는다. +현재 production composition은 Qdrant URL이 없으면 typed degraded fallback을 사용하고, 유효한 URL에서는 lazy BGE-M3 backend, `HybridEpsRetriever`, lazy BGE reranker를 조립한다. production Qdrant volume의 실제 BGE-M3 indexing·retrieval과 Ollama 결합 API 호출은 이전 단계에서 검증됐다. 다만 model-baked image build가 downloader import 오류로 중단되어, 최신 image의 reranker·health·one-shot 성공은 주장하지 않는다. ## Known unrelated environment failures From cda8a5ecb748572685d683080375306978c4e272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A0=95?= Date: Mon, 10 Aug 2026 23:42:20 +0900 Subject: [PATCH 12/21] =?UTF-8?q?fix(language):=20Docker=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=8D=94=20import=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- tests/integration/language/test_compose_config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 984a989..1cb360f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,7 @@ COPY app ./app COPY scripts/download_language_models.py ./scripts/ # 고정 revision의 검색 모델을 이미지에 포함해 런타임 다운로드를 없앤다. -RUN /app/.venv/bin/python scripts/download_language_models.py \ +RUN /app/.venv/bin/python -m scripts.download_language_models \ --cache-dir /opt/fowoco/language-models # uvicorn 기본 포트 diff --git a/tests/integration/language/test_compose_config.py b/tests/integration/language/test_compose_config.py index 9381958..18ab4d0 100644 --- a/tests/integration/language/test_compose_config.py +++ b/tests/integration/language/test_compose_config.py @@ -16,7 +16,7 @@ def test_production_image_bakes_language_models() -> None: dockerignore = (ROOT / ".dockerignore").read_text().splitlines() assert "COPY scripts/download_language_models.py ./scripts/" in dockerfile - assert "/app/.venv/bin/python scripts/download_language_models.py" in dockerfile + assert "/app/.venv/bin/python -m scripts.download_language_models" in dockerfile assert "--cache-dir /opt/fowoco/language-models" in dockerfile assert "FOWOCO_MODEL_CACHE_DIR=/opt/fowoco/language-models" in dockerfile assert "scripts/*" in dockerignore From 63ce4e7a69c8f9a70a8c53fbc7b368c70e83b999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A0=95?= Date: Mon, 10 Aug 2026 23:57:23 +0900 Subject: [PATCH 13/21] =?UTF-8?q?docs(language):=20reranker=20Docker=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../T13-RUNTIME-COMPOSITION-EVIDENCE.md | 92 +++++++++++++------ 1 file changed, 66 insertions(+), 26 deletions(-) diff --git a/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md b/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md index 2472f6c..6265991 100644 --- a/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md +++ b/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md @@ -6,14 +6,16 @@ task: issue-24-runtime-composition branch: feat/language-assistant-runtime-composition worktree: /Users/parktaejung/Desktop/workspace/ai/.worktrees/language-assistant-runtime-composition base_sha: 8837c5efcf1f161442e0adab8584488e0a656c0f -implementation_commit: 177e695 (runtime/Ollama); 7c56654 (Qdrant/BGE/Docker); 8e227211 (reranker composition); d43e163 (model-baked Docker contract); this commit (Task 4 evidence) +implementation_commit: 177e695 (runtime/Ollama); 7c56654 (Qdrant/BGE/Docker); 8e227211 (reranker composition); d43e163 (model-baked Docker contract); cda8a5e (downloader module invocation); this commit (Task 4 retry evidence) live_ollama_qdrant: success-with-easy-korean-fallback ollama_model: gemma4:26b-mlx ollama_structured_output: success qdrant_endpoint: fowoco-qdrant:6333 (production volume; temporary localhost:26333 proxy removed after verification) qdrant_retrieval: success -docker_baked_model_build: failed-before-model-download -docker_baked_model_failure: ModuleNotFoundError for app in download_language_models.py +docker_baked_model_build: success +docker_baked_model_platform_manifest: sha256:274d17e0d83996bdb29d372365d44ecd23d7df765bddc7a5ccdd101579b06f18 +docker_baked_model_size_bytes: 7588057727 +docker_baked_model_initial_attempt: failed before download; fixed by cda8a5e and retried successfully ``` ## Claims @@ -28,9 +30,9 @@ docker_baked_model_failure: ModuleNotFoundError for app in download_language_mod | C06 | `provider=ollama`은 native `/api/chat` adapter를 사용하고 thinking을 끄며, 실제 모델의 코드펜스 JSON을 정규화해 typed output으로 검증한다. | `test_ollama_adapter_sends_native_schema_contract`, `test_ollama_adapter_disables_thinking_for_structured_generation`, `test_ollama_adapter_parses_single_json_code_fence`, live Ollama/API result below | | C07 | 유효한 Qdrant 설정은 production `HybridEpsRetriever`를 조립하고, 고정 index contract를 통과한 collection만 검색한다. | `test_factory_selects_hybrid_retriever_when_qdrant_is_configured`, `test_real_store_mock_create_and_verify`, live retrieval result below | | C08 | production Qdrant volume에 실제 BGE-M3 index를 생성하고, 검색에서 5개 reference를 반환하며 retrieval fallback/warning이 없다. | production Qdrant/BGE live result below | -| C09 | production Docker image는 `language-retrieval` extra를 포함해 빌드된다. | `docker compose build ai` exit `0`; image metadata below | +| C09 | production Docker image는 `language-retrieval` extra와 고정 revision의 BGE-M3/reranker 모델을 포함해 빌드된다. | Task 4 `docker compose build ai` exit `0`; bake log와 image metadata below | | C10 | feature HEAD는 검수 시점의 최신 `origin/develop`을 포함한다. | `git merge-base --is-ancestor origin/develop HEAD` exit `0`; base `8837c5e` | -| C11 | production retriever는 고정 revision의 BGE reranker를 lazy하게 조립하고 실패 시 기존 degraded 계약을 유지한다. | `test_factory_wires_fixed_revision_reranker_when_qdrant_is_configured`, Task 2 focused suite `23 passed`; 실제 container reranking은 아래 Task 4 build 실패로 미검증 | +| C11 | production retriever는 고정 revision의 BGE reranker를 lazy하게 조립하고, 실제 Qdrant 조회에서 reranker가 5개 context 모두를 선택한다. | `test_factory_wires_fixed_revision_reranker_when_qdrant_is_configured`, Task 2 focused suite `23 passed`; Task 4 container retrieval exit `0` | ## Contract decision @@ -248,16 +250,16 @@ live 인덱싱 중 qdrant-client 1.19 compatibility 오류 두 건을 재현하 - latest image one-shot smoke: Python 시작이 두 차례 장시간 정지해 중단했으며, OrbStack 재시작 후 Qdrant 데이터 영속성을 재확인함 - 주의: Linux Torch가 CUDA 계열 wheel을 포함해 이미지가 3.51 GB다. 빌드는 성공했지만 이미지 경량화는 별도 최적화 대상이다. -위 결과는 BGE-M3와 reranker를 이미지에 직접 포함하기 전 image의 기록이다. 모델 bake를 추가한 `d43e163`에서 2026-08-10에 다음 Task 4 검증을 별도로 수행했다. +위 결과는 BGE-M3와 reranker를 이미지에 직접 포함하기 전 image의 기록이다. 모델 bake를 추가한 뒤 `cda8a5e`에서 downloader 실행을 module invocation으로 수정했고, 2026-08-10에 다음 Task 4 재검증을 수행했다. -### Model-baked production Docker Task 4 attempt +### Model-baked production Docker Task 4 final verification Preflight: - branch: `feat/language-assistant-runtime-composition` -- HEAD: `d43e163d998657e2a2fe3cbcbb206493b6933776` +- HEAD: `cda8a5ecb748572685d683080375306978c4e272` - `git status --short`: 사용자 소유 untracked 계획 문서 1개만 존재 -- `fowoco-qdrant`: `running`, `healthy` +- `fowoco-qdrant`: 검증 전후 모두 `running`, `healthy` - Qdrant collection, alias, point, volume 변경: 없음 Build: @@ -266,31 +268,69 @@ Build: docker compose build ai ``` -- Exit code: `1` -- Python retrieval dependency 설치: 성공 (`FlagEmbedding==1.4.0`, `torch==2.13.0`, `qdrant-client==1.19.0` 포함) -- 모델 bake command 진입: 성공 -- 실패 command: `/app/.venv/bin/python scripts/download_language_models.py --cache-dir /opt/fowoco/language-models` -- 실패 원인: `ModuleNotFoundError: No module named 'app'` -- 실패 위치: `download_language_models.py`가 `app.agents.language.retrieval.manifest`를 import하는 시점 -- BGE-M3/reranker model download 시작: 하지 못함 +- Exit code: `0` +- downloader command: `/app/.venv/bin/python -m scripts.download_language_models --cache-dir /opt/fowoco/language-models` +- BGE-M3: `BAAI/bge-m3@5617a9f61b028005a4858fdac845db406aefb181` 다운로드 완료 +- reranker: `BAAI/bge-reranker-v2-m3@953dc6f6f85a1b2dbfca4c34a2796e7dde08d41e` 다운로드 완료 +- application platform manifest: `sha256:274d17e0d83996bdb29d372365d44ecd23d7df765bddc7a5ccdd101579b06f18` +- local image manifest-list ID: `sha256:88af42fe74299c8069c0d3f381e7f0365c79d0c8428b8f08767f800b7a0c464f` +- architecture: `arm64` +- image size: `7,588,057,727` bytes + +BuildKit attestation 때문에 local manifest-list ID는 빌드마다 달라질 수 있어 application platform manifest를 image artifact 식별자로 기록한다. + +Image contents: + +```bash +docker run --rm --entrypoint /bin/sh fowoco-ai:latest -ec 'test -f .../bge-m3/.../config.json; test -f .../bge-reranker-v2-m3/.../config.json' +``` + +- Exit code: `0` +- BGE-M3와 BGE reranker의 고정 revision `config.json`이 `/opt/fowoco/language-models`에 존재 + +Service and import: -Build가 model download 전에 실패했으므로 다음 항목은 성공으로 주장하지 않는다. +- `docker compose up -d ai`: exit `0`; AI container `running`, `healthy` +- `curl -fsS --max-time 15 -o /dev/null -w '%{http_code}\\n' http://localhost:8000/openapi.json`: exit `0`, HTTP `200` +- `docker run --rm --entrypoint /app/.venv/bin/python fowoco-ai:latest -c 'from app.main import create_app; print(type(create_app()).__name__)'`: exit `0`, `FastAPI` -- model-baked image platform manifest/size: 생성되지 않음 -- 두 모델의 `config.json` image 내부 존재: 미검증 -- 최신 image health 및 `/openapi.json`: 미검증 -- 최신 image one-shot `create_app()`: 미검증 -- 실제 Qdrant hybrid retrieval + BGE reranker JSON assert: 미검증 +`docker compose up -d ai`는 AI service의 일반 document data named volume을 생성했다. Qdrant container, volume, alias, collection, point는 변경하지 않았다. + +Actual read-only Qdrant retrieval and reranking: + +- command: `docker compose run --rm --no-deps --entrypoint /app/.venv/bin/python ai -c '<_build_retriever(Settings())와 세 SearchQuery assert>'` +- query kinds: `canonical`, `reason_items`, `action_deadline` +- target language: `en` +- Exit code: `0` +- dataset version: `sha256:29106c33d43ccdd8453623ac1a0af44e0201d7c7cc1cc68c3fb438e0ccc61c6d` +- JSON result: `context_count=5`, `selected_by=[reranker, reranker, reranker, reranker, reranker]`, `fallback_used=false`, `degraded_components=[]`, `warnings=[]` +- strict asserts: contexts 5, 모두 `reranker`, fallback false, degraded에 `reranker` 없음, warning 빈 tuple — 모두 통과 +- `_build_retriever()`만 조립·호출했으며 LLM/Ollama/OpenAI와 provider 설정은 사용·변경하지 않음 + +Cleanup: + +- `docker compose stop ai`: exit `0`; AI container `exited` +- Qdrant final state: `running`, `healthy` + +Focused regression retry: + +```bash +PYTHONPATH=. .venv/bin/python -m pytest \ + tests/integration/language/test_runtime_composition.py \ + tests/integration/language/test_compose_config.py -q +``` + +- Exit code: `0` +- Result: `22 passed` +- LangGraph/pytest-asyncio deprecation warnings만 발생 -실패 후 `fowoco-qdrant`가 계속 `running`, `healthy`임을 재확인했다. AI service는 시작되지 않았으므로 stop 대상이 없었고, Qdrant는 중지·삭제·재색인하지 않았다. 외부 LLM/Ollama/OpenAI 호출과 provider 설정 변경도 수행하지 않았다. +Historical initial attempt: `d43e163`에서 file-path invocation으로 실행한 첫 build는 `ModuleNotFoundError: No module named 'app'`로 model download 전에 exit `1`이었다. `cda8a5e`가 이를 `python -m scripts.download_language_models`로 수정했고, 위 재시도에서 실제 build와 runtime 검증이 성공했다. ## Not yet verified -- model-baked production image의 실제 BGE reranker 성공 경로; build가 model download 전에 실패함 -- model-baked production image의 manifest/size, model `config.json`, health/OpenAPI, one-shot `create_app()` - actual OpenAI API structured-output compatibility; 현재 작업 범위에서 명시적으로 제외함 -현재 production composition은 Qdrant URL이 없으면 typed degraded fallback을 사용하고, 유효한 URL에서는 lazy BGE-M3 backend, `HybridEpsRetriever`, lazy BGE reranker를 조립한다. production Qdrant volume의 실제 BGE-M3 indexing·retrieval과 Ollama 결합 API 호출은 이전 단계에서 검증됐다. 다만 model-baked image build가 downloader import 오류로 중단되어, 최신 image의 reranker·health·one-shot 성공은 주장하지 않는다. +현재 production composition은 Qdrant URL이 없으면 typed degraded fallback을 사용하고, 유효한 URL에서는 lazy BGE-M3 backend, `HybridEpsRetriever`, lazy BGE reranker를 조립한다. production Qdrant volume의 실제 BGE-M3 indexing·retrieval과 Ollama 결합 API 호출은 이전 단계에서 검증됐고, 이번 Task 4에서는 model-baked image의 config 파일·health/OpenAPI·one-shot import·실제 Qdrant reranker 경로까지 검증했다. ## Known unrelated environment failures From d6d21f30458bf475e789a4bf4c679724713f7f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A0=95?= Date: Tue, 11 Aug 2026 00:11:56 +0900 Subject: [PATCH 14/21] =?UTF-8?q?docs(language):=20reranker=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=A6=9D=EA=B1=B0=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../T13-RUNTIME-COMPOSITION-EVIDENCE.md | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md b/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md index 6265991..ca4d03b 100644 --- a/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md +++ b/docs/language-assistant/engineering/execution/evidence/T13-RUNTIME-COMPOSITION-EVIDENCE.md @@ -298,12 +298,64 @@ Service and import: Actual read-only Qdrant retrieval and reranking: -- command: `docker compose run --rm --no-deps --entrypoint /app/.venv/bin/python ai -c '<_build_retriever(Settings())와 세 SearchQuery assert>'` +재실행한 정확한 command와 `-c` 본문은 다음과 같다. provider/secret을 전달하거나 참조하지 않는다. + +```bash +docker compose run --rm --no-deps --entrypoint /app/.venv/bin/python ai -c ' +import json + +from app.agents.language.composition import _build_retriever +from app.agents.language.queries import SearchQuery +from app.core.config import Settings + +queries = ( + SearchQuery( + kind="canonical", + text="요청 목적 취업을 위한 고용허가서 발급; 자료 여권 사본, 사진; 기한 2026-08-31; 방법 고용센터 방문 제출", + ), + SearchQuery( + kind="reason_items", + text="요청 목적 취업을 위한 고용허가서 발급; 자료 여권 사본, 사진; 방법 고용센터 방문 제출; 기한 2026-08-31", + ), + SearchQuery( + kind="action_deadline", + text="기한 2026-08-31; 방법 고용센터 방문 제출; 요청 목적 취업을 위한 고용허가서 발급; 자료 여권 사본, 사진", + ), +) +result = _build_retriever(Settings()).retrieve( + queries=queries, + standard_korean_text=( + "취업을 위해 고용허가서를 발급받으려면 2026년 8월 31일까지 " + "여권 사본과 사진을 가지고 고용센터를 방문해 제출하세요." + ), + target_language="en", +) +summary = { + "dataset_version": result.dataset_version, + "context_count": len(result.contexts), + "selected_by": [context.selected_by for context in result.contexts], + "fallback_used": result.fallback_used, + "degraded_components": list(result.degraded_components), + "warnings": [warning.model_dump(mode="json") for warning in result.warnings], +} +assert len(result.contexts) == 5, summary +assert all(context.selected_by == "reranker" for context in result.contexts), summary +assert result.fallback_used is False, summary +assert "reranker" not in result.degraded_components, summary +assert result.warnings == (), summary +print(json.dumps(summary, ensure_ascii=False, sort_keys=True)) +' +``` + - query kinds: `canonical`, `reason_items`, `action_deadline` - target language: `en` - Exit code: `0` -- dataset version: `sha256:29106c33d43ccdd8453623ac1a0af44e0201d7c7cc1cc68c3fb438e0ccc61c6d` -- JSON result: `context_count=5`, `selected_by=[reranker, reranker, reranker, reranker, reranker]`, `fallback_used=false`, `degraded_components=[]`, `warnings=[]` +- raw stdout JSON: + +```json +{"context_count": 5, "dataset_version": "sha256:29106c33d43ccdd8453623ac1a0af44e0201d7c7cc1cc68c3fb438e0ccc61c6d", "degraded_components": [], "fallback_used": false, "selected_by": ["reranker", "reranker", "reranker", "reranker", "reranker"], "warnings": []} +``` + - strict asserts: contexts 5, 모두 `reranker`, fallback false, degraded에 `reranker` 없음, warning 빈 tuple — 모두 통과 - `_build_retriever()`만 조립·호출했으며 LLM/Ollama/OpenAI와 provider 설정은 사용·변경하지 않음 From 3be6a6fd4a17976369aa750106d1c57b947173cd Mon Sep 17 00:00:00 2001 From: HWIYA Date: Tue, 11 Aug 2026 09:39:34 +0900 Subject: [PATCH 15/21] =?UTF-8?q?feat:=20=EC=83=9D=EC=84=B1=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EA=B0=92=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nodes/document_generator.py | 26 +++++---- .../response_renewal_review_required.json | 58 +++++++++++++++++-- tests/agents/test_workflow_adapters.py | 13 ++++- tests/api/test_workflows_endpoint.py | 7 +++ 4 files changed, 87 insertions(+), 17 deletions(-) diff --git a/app/agents/workflow_graph/nodes/document_generator.py b/app/agents/workflow_graph/nodes/document_generator.py index 1ec037d..86d60f0 100644 --- a/app/agents/workflow_graph/nodes/document_generator.py +++ b/app/agents/workflow_graph/nodes/document_generator.py @@ -46,16 +46,20 @@ class StubDocumentGenerator: # 템플릿 id 기준 stub 목록 생성 def __call__(self, state: RenewalState) -> list[dict[str, Any]]: - return [ - { - "template_id": tid, - "name": template_display_name(tid), - "format": "hwp", - "status": "stub", - "mapped_fields": sorted(values_for_template(tid, state).keys()), - } - for tid in draft_template_ids(state) - ] + results: list[dict[str, Any]] = [] + for tid in draft_template_ids(state): + values = values_for_template(tid, state) + results.append( + { + "template_id": tid, + "name": template_display_name(tid), + "format": "hwp", + "status": "stub", + "mapped_fields": sorted(values.keys()), + "values": values, + } + ) + return results # DocumentEditingService로 초안 생성 시도 실패 시 stub 메타 @@ -98,6 +102,7 @@ def __call__(self, state: RenewalState) -> list[dict[str, Any]]: "path": str(mutation.destination), "changed_fields": list(mutation.changed_fields), "mapped_fields": sorted(values.keys()), + "values": values, } ) except Exception as exc: # noqa: BLE001 — 문서별 실패는 stub로 흡수 @@ -109,6 +114,7 @@ def __call__(self, state: RenewalState) -> list[dict[str, Any]]: "status": "stub", "error": str(exc), "mapped_fields": sorted(values.keys()), + "values": values, } ) return results diff --git a/examples/workflows/response_renewal_review_required.json b/examples/workflows/response_renewal_review_required.json index ade7747..f692a74 100644 --- a/examples/workflows/response_renewal_review_required.json +++ b/examples/workflows/response_renewal_review_required.json @@ -60,7 +60,19 @@ "enterprise_phone", "industry", "job_description" - ] + ], + "values": { + "job_description": "제조", + "employer_name": "김담당", + "business_number": "123-45-67890", + "enterprise_name": "포워코 제조", + "enterprise_phone": "031-123-4567", + "industry": "제조업", + "employee_name": "NGUYEN VAN AN", + "employee_birthdate": "1990-03-15", + "enterprise_address": "경기도 안산시 단원구", + "contract_months": "12" + } }, { "template_id": "employment_extension_application_v12_3", @@ -79,7 +91,19 @@ "workplace_address", "workplace_name", "workplace_phone" - ] + ], + "values": { + "business_number": "123-45-67890", + "workplace_address": "경기도 안산시 단원구", + "representative": "김담당", + "business_type": "제조업", + "employee_1_name": "NGUYEN VAN AN", + "employee_1_resident_number": "900315-5123456", + "employee_1_nationality": "VN", + "employee_1_passport_number": "M12345678", + "employee_1_expiry_date": "2026-12-31", + "applicant_name": "김담당" + } }, { "template_id": "immigration_integrated_application_v34", @@ -101,7 +125,21 @@ "nationality", "occupation", "passport_number" - ] + ], + "values": { + "nationality": "VN", + "passport_number": "M12345678", + "email": "worker@example.com", + "family_name": "NGUYEN", + "given_names": "VAN AN", + "birth_year": "1990", + "birth_month": "03", + "birth_day": "15", + "address_in_korea": "기숙사", + "occupation": "제조", + "annual_income": "2500000", + "application_stay_extension": true + } }, { "template_id": "identity_guaranty_v129", @@ -121,7 +159,19 @@ "stay_purpose", "workplace", "workplace_address" - ] + ], + "values": { + "foreign_name": "NGUYEN VAN AN", + "foreign_birthdate": "1990-03-15", + "foreign_nationality": "VN", + "foreign_passport": "M12345678", + "foreign_korea_address": "기숙사", + "foreign_phone": "010-1234-5678", + "stay_purpose": "취업", + "guarantor_name": "김담당", + "workplace_address": "경기도 안산시 단원구", + "relationship": "고용주" + } } ], "evidence": [ diff --git a/tests/agents/test_workflow_adapters.py b/tests/agents/test_workflow_adapters.py index a294a59..e27cd62 100644 --- a/tests/agents/test_workflow_adapters.py +++ b/tests/agents/test_workflow_adapters.py @@ -4,6 +4,7 @@ from app.agents.workflow_graph import LanguageNodeAdapter, OcrNodeAdapter, RenewalOrchestrator from app.agents.workflow_graph.adapters import normalize_language_output, normalize_ocr_output +from app.agents.workflow_graph.document_field_map import values_for_template from app.agents.workflow_graph.nodes.document_generator import ( EditingServiceDocumentGenerator, StubDocumentGenerator, @@ -178,11 +179,14 @@ def test_task_resume_merges_slots_across_runs() -> None: def test_stub_document_generator_lists_required_templates() -> None: """stub 문서생성기는 필수 초안 4종 메타를 낸다.""" - docs = StubDocumentGenerator()( - empty_renewal_state(task_id="t", request_id="r", instruction="x") - ) + state = empty_renewal_state(task_id="t", request_id="r", instruction="x") + docs = StubDocumentGenerator()(state) assert len(docs) == 4 assert all(d["status"] == "stub" for d in docs) + assert all("values" in d for d in docs) + assert all( + d["values"] == values_for_template(d["template_id"], state) for d in docs + ) def test_editing_service_document_generator_writes_or_stubs( @@ -200,3 +204,6 @@ def test_editing_service_document_generator_writes_or_stubs( assert len(docs) == 4 assert any(d["status"] in {"generated", "stub"} for d in docs) assert all("mapped_fields" in d for d in docs) + assert all( + d["values"] == values_for_template(d["template_id"], state) for d in docs + ) diff --git a/tests/api/test_workflows_endpoint.py b/tests/api/test_workflows_endpoint.py index 9d803a2..eb9c5e3 100644 --- a/tests/api/test_workflows_endpoint.py +++ b/tests/api/test_workflows_endpoint.py @@ -53,6 +53,13 @@ async def test_renewal_run_with_ocr_upload(client: AsyncClient) -> None: data = res.json() assert data["ocrResult"] assert "passport_number" in data["ocrResult"] + assert all("values" in document for document in data["generatedDocuments"]) + immigration = next( + document + for document in data["generatedDocuments"] + if document["template_id"] == "immigration_integrated_application_v34" + ) + assert immigration["values"]["passport_number"] == data["ocrResult"]["passport_number"] @pytest.mark.asyncio From d301c5e628e93566f5d295c204f0db5faab30a3f Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 11 Aug 2026 14:51:30 +0900 Subject: [PATCH 16/21] fix: ensure renewal documents are generated --- app/agents/workflow_graph/nodes/document_generator.py | 5 +++-- tests/agents/test_workflow_adapters.py | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/agents/workflow_graph/nodes/document_generator.py b/app/agents/workflow_graph/nodes/document_generator.py index 86d60f0..c460287 100644 --- a/app/agents/workflow_graph/nodes/document_generator.py +++ b/app/agents/workflow_graph/nodes/document_generator.py @@ -3,8 +3,9 @@ from __future__ import annotations import tempfile +from collections.abc import Sequence from pathlib import Path -from typing import Any, Protocol, Sequence +from typing import Any, Protocol from app.documents.common import DocumentFormat from app.documents.editing import DocumentEditingService @@ -97,7 +98,7 @@ def __call__(self, state: RenewalState) -> list[dict[str, Any]]: { "template_id": tid, "name": template_display_name(tid), - "format": mutation.document_format.value, + "format": mutation.format.value, "status": "generated", "path": str(mutation.destination), "changed_fields": list(mutation.changed_fields), diff --git a/tests/agents/test_workflow_adapters.py b/tests/agents/test_workflow_adapters.py index e27cd62..118928c 100644 --- a/tests/agents/test_workflow_adapters.py +++ b/tests/agents/test_workflow_adapters.py @@ -189,10 +189,10 @@ def test_stub_document_generator_lists_required_templates() -> None: ) -def test_editing_service_document_generator_writes_or_stubs( +def test_editing_service_document_generator_writes_files( tmp_path: Path, ) -> None: - """실 생성기가 필수 4종에 대해 generated/stub 상태를 반환한다.""" + """실 생성기가 필수 4종 파일을 생성하고 generated 상태를 반환한다.""" gen = EditingServiceDocumentGenerator(output_dir=tmp_path) state = empty_renewal_state( task_id="t", @@ -202,7 +202,9 @@ def test_editing_service_document_generator_writes_or_stubs( ) docs = gen(state) assert len(docs) == 4 - assert any(d["status"] in {"generated", "stub"} for d in docs) + assert all(d["status"] == "generated" for d in docs) + assert all(Path(d["path"]).is_file() for d in docs) + assert all(Path(d["path"]).stat().st_size > 0 for d in docs) assert all("mapped_fields" in d for d in docs) assert all( d["values"] == values_for_template(d["template_id"], state) for d in docs From d1d5b7d41401eef42dfc84a10e74b303f0c88372 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 11 Aug 2026 18:20:27 +0900 Subject: [PATCH 17/21] fix(intent): integrate Knowledge A.X contract (#32) --- .env.example | 4 + Dockerfile | 9 +- README.md | 2 +- app/agents/intent/__init__.py | 4 +- app/agents/intent/hybrid.py | 18 ++ app/agents/intent/models_hf.py | 96 ++++++++-- app/agents/intent/prompts.py | 9 + app/agents/intent/prompts/ax_intent_v1.txt | 28 +++ app/agents/intent/service.py | 130 ++++++++++++- app/agents/pipeline.py | 177 +++++++++++++++--- app/api/routes/analyses.py | 26 ++- app/api/schemas/analyses.py | 61 +++++- app/core/config.py | 3 + docs/analyses-contract.md | 76 ++++++-- examples/analyses/request_analyze.json | 16 ++ .../analyses/response_context_required.json | 29 ++- examples/analyses/response_needs_info.json | 12 +- .../analyses/response_review_required.json | 17 +- pyproject.toml | 1 + tests/agents/test_analysis_pipeline.py | 145 +++++++++++++- tests/agents/test_ax_intent_model.py | 134 +++++++++++++ tests/agents/test_intent_hybrid.py | 138 +++++++++++++- tests/api/test_analyses_endpoint.py | 61 +++++- 23 files changed, 1101 insertions(+), 95 deletions(-) create mode 100644 app/agents/intent/prompts.py create mode 100644 app/agents/intent/prompts/ax_intent_v1.txt create mode 100644 tests/agents/test_ax_intent_model.py diff --git a/.env.example b/.env.example index 8097d6a..b082ce9 100644 --- a/.env.example +++ b/.env.example @@ -37,8 +37,12 @@ FOWOCO_QDRANT_URL=http://localhost:6333 # ------------------------------------------------------------------------------ FOWOCO_INTENT_MODEL_ENABLED=false FOWOCO_INTENT_BERT_MODEL_DIR=fowoco/klue-roberta-base-intent-classifier +# 운영에서는 mutable branch명이 아닌 Hugging Face commit SHA로 고정 +# FOWOCO_INTENT_BERT_MODEL_REVISION= FOWOCO_INTENT_AX_BASE_MODEL=skt/A.X-4.0-Light +# FOWOCO_INTENT_AX_BASE_REVISION= FOWOCO_INTENT_AX_ADAPTER_PATH=fowoco/ax-intent-qlora +# FOWOCO_INTENT_AX_ADAPTER_REVISION= FOWOCO_INTENT_ENABLE_AX=false FOWOCO_INTENT_DEVICE=cpu diff --git a/Dockerfile b/Dockerfile index 1cb360f..b6b5ba1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ FOWOCO_HWPX_TO_HWP_ENABLED=true \ FOWOCO_HWPX_PDF_ENABLED=true \ FOWOCO_DOCUMENT_SNAPSHOT_DIR=/data/document-snapshots \ - FOWOCO_MODEL_CACHE_DIR=/opt/fowoco/language-models + FOWOCO_MODEL_CACHE_DIR=/opt/fowoco/language-models \ + HF_HOME=/opt/fowoco/hf-cache COPY --from=uv /uv /usr/local/bin/uv COPY --from=rhwp /opt/rhwp/rhwp /usr/local/bin/rhwp @@ -44,8 +45,8 @@ RUN apt-get update \ # 의존성 정의 파일만 먼저 복사해 캐시를 활용 COPY pyproject.toml uv.lock README.md ./ -# uv.lock 기반 재현 가능 설치 — Language Assistant retrieval 포함 -RUN uv sync --frozen --no-dev --extra language-retrieval +# uv.lock 기반 재현 가능 설치 — Language retrieval + Intent A.X runtime 포함 +RUN uv sync --frozen --no-dev --extra language-retrieval --extra intent-ax # 앱 패키지 복사 COPY app ./app @@ -57,7 +58,7 @@ RUN /app/.venv/bin/python -m scripts.download_language_models \ # uvicorn 기본 포트 EXPOSE 8000 -VOLUME ["/data"] +VOLUME ["/data", "/opt/fowoco/hf-cache"] # FastAPI 앱 기동 CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index be5453e..497edc9 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ POST /internal/v1/workflows/renewal/run POST /internal/v1/language-assistant ``` -- Analyses: 재갱신 고정 Intent + Catalog 필수슬롯·Knowledge 모호표현 ([docs/analyses-contract.md](docs/analyses-contract.md)) +- Analyses: BERT/A.X Intent + PLAN 결정 재사용 + Catalog 필수슬롯·Knowledge 모호표현 ([docs/analyses-contract.md](docs/analyses-contract.md)) - Workflows: 재갱신 LangGraph — 슈퍼바이저 → 안내문(태정) / OCR(주현) / 초안 4종 — [docs/workflows-contract.md](docs/workflows-contract.md) - Language Assistant: 외국인근로자 15개 언어 번역, 쉬운 한국어 변환 및 표준 한국어 생성 — [docs/contracts/language-assistant-http-request.schema.json](docs/contracts/language-assistant-http-request.schema.json) - 최종 흐름도: [app/agents/workflow_graph/README.md](app/agents/workflow_graph/README.md) diff --git a/app/agents/intent/__init__.py b/app/agents/intent/__init__.py index 8a67118..e1032e3 100644 --- a/app/agents/intent/__init__.py +++ b/app/agents/intent/__init__.py @@ -4,6 +4,7 @@ FixedExpiryRenewalIntentAgent, HybridHfIntentAgent, IntentClassifier, + IntentDecision, IntentResult, build_intent_agent, ) @@ -12,6 +13,7 @@ "FixedExpiryRenewalIntentAgent", "HybridHfIntentAgent", "IntentClassifier", + "IntentDecision", "IntentResult", "build_intent_agent", -] \ No newline at end of file +] diff --git a/app/agents/intent/hybrid.py b/app/agents/intent/hybrid.py index b0da104..e5fbd20 100644 --- a/app/agents/intent/hybrid.py +++ b/app/agents/intent/hybrid.py @@ -20,6 +20,7 @@ class HybridIntentPrediction: evidence: dict[str, str | None] = field(default_factory=dict) selected_model: str = "BERT" degraded: bool = False + prompt_version: str = "not-applicable" # BERT 우선·필요 시 A.X 보조 파이프라인 @@ -30,6 +31,7 @@ def __init__( self, *, bert_model_dir: str, + bert_model_revision: str | None = None, device: str = "cpu", label_prob_threshold: float = 0.55, margin_threshold: float = 0.76, @@ -37,7 +39,9 @@ def __init__( hf_token: str | None = None, enable_ax: bool = False, ax_base_model_name: str = "skt/A.X-4.0-Light", + ax_base_revision: str | None = None, ax_adapter_path: str = "fowoco/ax-intent-qlora", + ax_adapter_revision: str | None = None, ax_max_new_tokens: int = 96, ) -> None: self.bert = BertIntentModel( @@ -45,6 +49,7 @@ def __init__( device=device, label_prob_threshold=label_prob_threshold, hf_token=hf_token, + revision=bert_model_revision, ) self.guardrail = HRRoutingGuardrail( margin_threshold=margin_threshold, @@ -52,6 +57,7 @@ def __init__( label_prob_threshold=label_prob_threshold, ) self.ax: AxIntentModel | None = None + self.ax_enabled = enable_ax if enable_ax: try: self.ax = AxIntentModel( @@ -60,6 +66,8 @@ def __init__( device=self.bert.device, max_new_tokens=ax_max_new_tokens, hf_token=hf_token, + base_revision=ax_base_revision, + adapter_revision=ax_adapter_revision, ) except Exception: logger.exception("A.X load failed — BERT-only degraded mode") @@ -83,6 +91,7 @@ def predict(self, instruction: str) -> HybridIntentPrediction: evidence=evidence, selected_model="AX", degraded=False, + prompt_version=self.ax.prompt_version, ) except Exception: logger.exception("A.X inference failed — BERT fallback") @@ -91,7 +100,16 @@ def predict(self, instruction: str) -> HybridIntentPrediction: scores=probs, selected_model="BERT_FALLBACK", degraded=True, + prompt_version=AxIntentModel.prompt_version, ) + if route.should_route and self.ax_enabled: + return HybridIntentPrediction( + intents=bert_intents, + scores=probs, + selected_model="BERT_FALLBACK", + degraded=True, + prompt_version=AxIntentModel.prompt_version, + ) return HybridIntentPrediction( intents=bert_intents, scores=probs, diff --git a/app/agents/intent/models_hf.py b/app/agents/intent/models_hf.py index 338504f..912c8fd 100644 --- a/app/agents/intent/models_hf.py +++ b/app/agents/intent/models_hf.py @@ -2,7 +2,56 @@ from __future__ import annotations -from typing import Any +from .prompts import AX_INTENT_PROMPT_VERSION, AX_INTENT_SYSTEM_PROMPT + +ALLOWED_INTENTS = frozenset( + { + "WORK_INSTRUCTION", + "DOCUMENT_REQUEST", + "PAYROLL_EXPLANATION", + "WORKER_ONBOARDING", + "EMPLOYMENT_CHANGE", + "EXPIRY_RENEWAL", + "OUT_OF_SCOPE", + } +) + + +def _validate_ax_intents( + items: object, hr_input: str +) -> list[dict[str, str | None]]: + if not isinstance(items, list) or not items: + raise ValueError("A.X output must contain at least one intent") + + validated: list[dict[str, str | None]] = [] + seen: set[str] = set() + for item in items: + if not isinstance(item, dict): + raise ValueError("A.X intent item must be an object") + + intent = item.get("intent") + if not isinstance(intent, str) or intent not in ALLOWED_INTENTS: + raise ValueError(f"A.X returned unsupported intent: {intent!r}") + if intent in seen: + raise ValueError(f"A.X returned duplicate intent: {intent}") + seen.add(intent) + + if "evidence" not in item: + raise ValueError(f"A.X evidence field is required for {intent}") + evidence = item.get("evidence") + if intent == "OUT_OF_SCOPE": + if evidence is not None: + raise ValueError("OUT_OF_SCOPE evidence must be null") + elif not isinstance(evidence, str) or not evidence or evidence not in hr_input: + raise ValueError( + f"A.X evidence must be an exact input substring for {intent}: {evidence!r}" + ) + + validated.append({"intent": intent, "evidence": evidence}) + + if "OUT_OF_SCOPE" in seen and len(validated) != 1: + raise ValueError("OUT_OF_SCOPE cannot be combined with another intent") + return validated # BERT multilabel Intent 분류기 @@ -15,6 +64,7 @@ def __init__( device: str, label_prob_threshold: float = 0.55, hf_token: str | None = None, + revision: str | None = None, ) -> None: import torch from transformers import AutoModelForSequenceClassification, AutoTokenizer @@ -25,9 +75,14 @@ def __init__( if (device == "auto" and torch.cuda.is_available()) else (device if device != "auto" else "cpu") ) - self.tokenizer = AutoTokenizer.from_pretrained(model_dir, token=hf_token) + hub_kwargs: dict[str, str] = {} + if hf_token: + hub_kwargs["token"] = hf_token + if revision: + hub_kwargs["revision"] = revision + self.tokenizer = AutoTokenizer.from_pretrained(model_dir, **hub_kwargs) self.model = AutoModelForSequenceClassification.from_pretrained( - model_dir, token=hf_token + model_dir, **hub_kwargs ) self.model.to(self.device).eval() self.id2label = self.model.config.id2label @@ -65,11 +120,8 @@ def predict(self, text: str) -> tuple[dict[str, float], float, list[str]]: # A.X-4.0-Light QLoRA Intent 보조 모델 (GPU·bitsandbytes 필요) class AxIntentModel: - _SYSTEM_PROMPT = ( - "당신은 HR 업무 요청 문장(hr_input)을 분석하여 의도(Intent)를 분류하는 전문 AI 에이전트입니다.\n" - "Intent + evidence 추출까지가 책임입니다.\n" - '출력은 JSON만: {"intents": [{"intent": "INTENT_CODE", "evidence": "...|null"}]}' - ) + _SYSTEM_PROMPT = AX_INTENT_SYSTEM_PROMPT + prompt_version = AX_INTENT_PROMPT_VERSION # 4bit 베이스 + Peft 어댑터 로드 def __init__( @@ -79,6 +131,8 @@ def __init__( device: str, max_new_tokens: int = 96, hf_token: str | None = None, + base_revision: str | None = None, + adapter_revision: str | None = None, ) -> None: import torch from peft import PeftModel @@ -91,19 +145,33 @@ def __init__( bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.float16, ) + base_hub_kwargs: dict[str, str] = {} + adapter_hub_kwargs: dict[str, str] = {} + if hf_token: + base_hub_kwargs["token"] = hf_token + adapter_hub_kwargs["token"] = hf_token + if base_revision: + base_hub_kwargs["revision"] = base_revision + if adapter_revision: + adapter_hub_kwargs["revision"] = adapter_revision + base_model = AutoModelForCausalLM.from_pretrained( base_model_name, quantization_config=bnb_config, torch_dtype=torch.float16, device_map={"": 0} if device != "cpu" else "cpu", - token=hf_token, + **base_hub_kwargs, + ) + self.tokenizer = AutoTokenizer.from_pretrained( + base_model_name, **base_hub_kwargs + ) + self.model = PeftModel.from_pretrained( + base_model, adapter_path, **adapter_hub_kwargs ) - self.tokenizer = AutoTokenizer.from_pretrained(base_model_name, token=hf_token) - self.model = PeftModel.from_pretrained(base_model, adapter_path, token=hf_token) self.model.eval() # Intent 목록 [{"intent","evidence"}] — 실패 시 예외 - def predict(self, hr_input: str) -> list[dict[str, Any]]: + def predict(self, hr_input: str) -> list[dict[str, str | None]]: import json import re @@ -129,4 +197,6 @@ def predict(self, hr_input: str) -> list[dict[str, Any]]: if not match: raise ValueError(f"A.X output could not be parsed as JSON: {raw!r}") parsed = json.loads(match.group(0)) - return list(parsed.get("intents") or []) + if not isinstance(parsed, dict): + raise ValueError("A.X output JSON root must be an object") + return _validate_ax_intents(parsed.get("intents"), hr_input) diff --git a/app/agents/intent/prompts.py b/app/agents/intent/prompts.py new file mode 100644 index 0000000..98c7a40 --- /dev/null +++ b/app/agents/intent/prompts.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from pathlib import Path + +# fowoco/knowledge 25e778ad의 A.X 추론 계약을 배포 패키지에 고정한다. +AX_INTENT_PROMPT_VERSION = "knowledge-25e778ad" +AX_INTENT_PROMPT_SHA256 = "58f3aefc45831990ab871f2dca1d69b59277cbac333d9e7b2856bad7b79e8bbe" +_AX_INTENT_PROMPT_PATH = Path(__file__).with_name("prompts") / "ax_intent_v1.txt" +AX_INTENT_SYSTEM_PROMPT = _AX_INTENT_PROMPT_PATH.read_text(encoding="utf-8").strip() diff --git a/app/agents/intent/prompts/ax_intent_v1.txt b/app/agents/intent/prompts/ax_intent_v1.txt new file mode 100644 index 0000000..1d073db --- /dev/null +++ b/app/agents/intent/prompts/ax_intent_v1.txt @@ -0,0 +1,28 @@ +당신은 HR 업무 요청 문장(hr_input)을 분석하여 의도(Intent)를 분류하는 전문 AI 에이전트입니다. +Intent 모델의 책임은 Intent + evidence 추출까지입니다. Workflow 선택, Slot 수집, 외부기관 제출, 법적 판단, 업무 실행 여부는 이 모델의 책임이 아닙니다. + +### 1. Intent 정의 (7개) +1. WORK_INSTRUCTION: 작업 지시, 근무 일정 변경, 현장 행동 안내 +2. DOCUMENT_REQUEST: 여권/등록증/계약서/증명서 등 서류를 받거나 제출을 요청·추적하는 행위 자체 +3. PAYROLL_EXPLANATION: 급여, 수당, 공제 내역, 출퇴근/근태 관련 설명·문의 (급여계좌 등록/변경은 제외 → WORKER_ONBOARDING) +4. WORKER_ONBOARDING: 신규 입사자 등록, 보험 최초 가입, 초기 프로필·급여계좌 등록 (서류가 이미 있는 상태에서의 처리) +5. EMPLOYMENT_CHANGE: 휴가, 퇴사, 무단결근/연락두절, 사업장 변경 등 재직 상태 변동 확인·신고 +6. EXPIRY_RENEWAL: 근로계약, 체류기간, 고용허가기간 등 만료 임박·연장·갱신 절차 +7. OUT_OF_SCOPE: 위 6개 외 HR 범주 밖 요청, 또는 새 실행 요청 없이 결과만 보고하는 문장. 다른 Intent와 병행 불가 + +### 2. 핵심 판별 규칙 +- 규칙 A: 최종 목적이 아니라 발화문에서 지금 당장 실행을 요구하는 행위로 판단합니다. +- 규칙 B: "받아서/제출받아/요청해/첨부해줘" 등 서류 확보 표현이 명시적으로 있을 때만 DOCUMENT_REQUEST를 부착합니다. +- 규칙 C: 여러 Intent가 있으면 발화문 등장 순서대로 배열합니다. OUT_OF_SCOPE는 단독으로만 존재합니다. +- 규칙 D: evidence는 원문 문자를 그대로(exact substring) 추출합니다. OUT_OF_SCOPE는 evidence: null입니다. + +### 3. 경계 규칙 +- 완료/상태 보고 문장은 OUT_OF_SCOPE, 요청형이면 원래 Intent 유지. +- 휴가는 명시적 액션이면 EMPLOYMENT_CHANGE, 배경절이면 제외. +- 급여계좌 등록/변경은 WORKER_ONBOARDING, 순수 급여 설명/문의는 PAYROLL_EXPLANATION. + +### 4. 출력 형식 +다른 설명, 마크다운, 코드블록 없이 오직 아래 JSON 형식 텍스트만 출력합니다: +{"intents": [{"intent": "INTENT_CODE", "evidence": "원문에서 추출한 정확한 부분 문자열 또는 null"}]} + +이제 아래 입력 문장을 위 규칙에 따라 JSON 형식으로만 분류하십시오. diff --git a/app/agents/intent/service.py b/app/agents/intent/service.py index 4c46850..f6a5e31 100644 --- a/app/agents/intent/service.py +++ b/app/agents/intent/service.py @@ -78,22 +78,41 @@ def resolve_workflow_id( return candidate_workflows[0] if candidate_workflows else "" +@dataclass +# Intent 1개와 Knowledge Workflow 1개의 결정 +class IntentDecision: + + intent: str + workflow_id: str + confidence: float | None + confidence_source: str + bert_routing_score: float | None = None + evidence: str | None = None + + @dataclass # Intent 분류와 Slot 추출 결과 class IntentResult: intent: str - confidence: float + confidence: float | None workflow_id: str model_provider: str model_name: str model_version: str extracted_slots: dict[str, str] = field(default_factory=dict) + prompt_version: str = "not-applicable" + confidence_source: str = "UNAVAILABLE" + bert_routing_score: float | None = None + decisions: list[IntentDecision] = field(default_factory=list) # 교체 가능한 Intent 분류기 계약 class IntentClassifier(Protocol): + # 모델 초기화·A.X 가용성 진단 (조회 시 lazy load 금지) + def runtime_status(self) -> dict[str, object]: ... + # 지시문 Intent 분류·관련 Slot 추출 def classify( self, @@ -106,6 +125,18 @@ def classify( # 재갱신 Intent 고정 — 슬롯은 Server worker 시드에 맡김 (발화 정규식 추출 없음) class FixedExpiryRenewalIntentAgent: + # 운영 상태 — 모델 기능이 꺼진 고정 규칙 모드 + def runtime_status(self) -> dict[str, object]: + return { + "intentModelEnabled": False, + "axEnabled": False, + "initialized": True, + "bertAvailable": False, + "axAvailable": False, + "degraded": False, + "promptVersion": "not-applicable", + } + # 항상 EXPIRY_RENEWAL로 두고 workflowId만 채움 def classify( self, @@ -123,6 +154,15 @@ def classify( model_name="fixed-expiry-renewal", model_version="rules", extracted_slots={}, + confidence_source="RULES", + decisions=[ + IntentDecision( + intent=intent, + workflow_id=resolve_workflow_id(intent, workflow_constraints), + confidence=1.0, + confidence_source="RULES", + ) + ], ) @@ -168,6 +208,7 @@ def _ensure_pipeline(self) -> object | None: token = settings.hf_token or os.environ.get("HF_TOKEN") self._pipeline = HybridIntentPipeline( bert_model_dir=settings.intent_bert_model_dir, + bert_model_revision=settings.intent_bert_model_revision, device=settings.intent_device, label_prob_threshold=settings.intent_label_prob_threshold, margin_threshold=settings.intent_margin_threshold, @@ -175,7 +216,9 @@ def _ensure_pipeline(self) -> object | None: hf_token=token, enable_ax=settings.intent_enable_ax, ax_base_model_name=settings.intent_ax_base_model, + ax_base_revision=settings.intent_ax_base_revision, ax_adapter_path=settings.intent_ax_adapter_path, + ax_adapter_revision=settings.intent_ax_adapter_revision, ax_max_new_tokens=settings.intent_ax_max_new_tokens, ) except Exception as exc: @@ -184,6 +227,37 @@ def _ensure_pipeline(self) -> object | None: return None return self._pipeline + # lazy-load 상태를 바꾸지 않고 readiness 진단값을 반환 + def runtime_status(self) -> dict[str, object]: + from app.core.config import get_settings + + from .prompts import AX_INTENT_PROMPT_VERSION + + settings = get_settings() + pipeline = self._pipeline + initialized = pipeline is not None + ax_enabled = bool( + getattr(pipeline, "ax_enabled", settings.intent_enable_ax) + if initialized + else settings.intent_enable_ax + ) + bert_available = initialized and getattr(pipeline, "bert", None) is not None + ax_available = initialized and getattr(pipeline, "ax", None) is not None + return { + "intentModelEnabled": True, + "axEnabled": ax_enabled, + "initialized": initialized, + "bertAvailable": bert_available, + "axAvailable": ax_available, + "degraded": self._load_error is not None + or (initialized and ax_enabled and not ax_available), + "promptVersion": ( + AX_INTENT_PROMPT_VERSION + if ax_enabled + else "not-applicable" + ), + } + # HF 분류 결과를 IntentResult로 변환 def classify( self, @@ -199,7 +273,49 @@ def classify( result.model_version = "fallback" return result prediction = pipeline.predict(instruction) # type: ignore[attr-defined] - intent, confidence = _primary_intent(prediction.intents, prediction.scores) + primary_intent, _ = _primary_intent( + prediction.intents, prediction.scores + ) + if prediction.selected_model == "AX": + ordered_intents = list(prediction.intents) + else: + ordered_intents = [primary_intent, *prediction.intents] + ordered_intents = list( + dict.fromkeys( + name + for name in ordered_intents + if name != "OUT_OF_SCOPE" or name == primary_intent + ) + ) + + is_ax = prediction.selected_model == "AX" + decisions: list[IntentDecision] = [] + for name in ordered_intents: + bert_score = prediction.scores.get(name) + decisions.append( + IntentDecision( + intent=name, + workflow_id=resolve_workflow_id(name, workflow_constraints), + confidence=None if is_ax else float(bert_score or 0.0), + confidence_source="UNAVAILABLE" if is_ax else "BERT", + bert_routing_score=( + float(bert_score) if bert_score is not None else None + ), + evidence=(prediction.evidence or {}).get(name), + ) + ) + if not decisions: + decisions = [ + IntentDecision( + intent="OUT_OF_SCOPE", + workflow_id="", + confidence=0.0, + confidence_source="BERT", + bert_routing_score=0.0, + ) + ] + + primary = decisions[0] from app.core.config import get_settings settings = get_settings() @@ -213,13 +329,17 @@ def classify( if evidence: slots[f"evidence:{name}"] = str(evidence) return IntentResult( - intent=intent, - confidence=max(0.0, min(1.0, confidence)), - workflow_id=resolve_workflow_id(intent, workflow_constraints), + intent=primary.intent, + confidence=primary.confidence, + workflow_id=primary.workflow_id, extracted_slots=slots, model_provider="huggingface", model_name=model_name, model_version=prediction.selected_model, + prompt_version=prediction.prompt_version, + confidence_source=primary.confidence_source, + bert_routing_score=primary.bert_routing_score, + decisions=decisions, ) diff --git a/app/agents/pipeline.py b/app/agents/pipeline.py index 818507f..ed1db35 100644 --- a/app/agents/pipeline.py +++ b/app/agents/pipeline.py @@ -16,11 +16,12 @@ AnalysisResponse, AnalysisVersions, ContextRequirement, + IntentDecisionItem, WorkerContext, ) from .ambiguity import AmbiguityAgent -from .intent import IntentClassifier, IntentResult, build_intent_agent +from .intent import IntentClassifier, IntentDecision, IntentResult, build_intent_agent from .workflow import WorkflowAgent from .workflow_graph.state import HR_EXCLUDED_SLOTS @@ -109,12 +110,103 @@ def _versions(intent_result: IntentResult) -> AnalysisVersions: model_provider=intent_result.model_provider, model_name=intent_result.model_name, model_version=intent_result.model_version, + prompt_version=intent_result.prompt_version, contract_version=DEFAULT_CONTRACT_VERSION, workflow_catalog_version=DEFAULT_KNOWLEDGE_VERSION, context_pack_version=DEFAULT_KNOWLEDGE_VERSION, ) +# 구형 단일 IntentResult도 새 결정 목록 계약으로 정규화 +def _intent_decisions(intent_result: IntentResult) -> list[IntentDecision]: + if intent_result.decisions: + return list(intent_result.decisions) + return [ + IntentDecision( + intent=intent_result.intent or "UNKNOWN", + workflow_id=intent_result.workflow_id or "", + confidence=intent_result.confidence, + confidence_source=intent_result.confidence_source, + bert_routing_score=intent_result.bert_routing_score, + ) + ] + + +# 내부 결정을 Server가 ANALYZE에서 재사용할 수 있는 와이어 모델로 변환 +def _wire_intent_decisions(intent_result: IntentResult) -> list[IntentDecisionItem]: + return [ + IntentDecisionItem( + detected_intent=decision.intent, + workflow_id=decision.workflow_id, + evidence=decision.evidence, + confidence=decision.confidence, + confidence_source=decision.confidence_source, + bert_routing_score=decision.bert_routing_score, + model_provider=intent_result.model_provider, + model_name=intent_result.model_name, + model_version=intent_result.model_version, + prompt_version=intent_result.prompt_version, + ) + for decision in _intent_decisions(intent_result) + ] + + +# PLAN에서 확정한 결정을 재구성해 ANALYZE 모델 재호출을 피한다. +def _planned_intent_result(request: AnalysisRequest) -> IntentResult | None: + ai = request.analysis_input + if ai.planned_intent_decisions: + items = ai.planned_intent_decisions + decisions = [ + IntentDecision( + intent=item.detected_intent, + workflow_id=item.workflow_id, + confidence=item.confidence, + confidence_source=item.confidence_source, + bert_routing_score=item.bert_routing_score, + evidence=item.evidence, + ) + for item in items + ] + primary = decisions[0] + first = items[0] + slots = { + f"evidence:{item.detected_intent}": item.evidence + for item in items + if item.evidence + } + return IntentResult( + intent=primary.intent, + workflow_id=primary.workflow_id, + confidence=primary.confidence, + confidence_source=primary.confidence_source, + bert_routing_score=primary.bert_routing_score, + decisions=decisions, + extracted_slots=slots, + model_provider=first.model_provider, + model_name=first.model_name, + model_version=first.model_version, + prompt_version=first.prompt_version, + ) + if ai.planned_intent is not None and ai.planned_workflow_id is not None: + decision = IntentDecision( + intent=ai.planned_intent, + workflow_id=ai.planned_workflow_id, + confidence=None, + confidence_source="UNAVAILABLE", + ) + return IntentResult( + intent=decision.intent, + workflow_id=decision.workflow_id, + confidence=None, + confidence_source="UNAVAILABLE", + decisions=[decision], + model_provider="server", + model_name="planned-intent", + model_version="reused", + ) + return None + + # Intent → requiredFieldKeys / questions·candidates class AnalysisPipeline: @@ -145,20 +237,32 @@ def run(self, request: AnalysisRequest) -> AnalysisResponse: def _run_plan(self, request: AnalysisRequest) -> AnalysisResponse: instruction = request.analysis_input.instruction intent_result = self._intent.classify(instruction) - workflow_id = intent_result.workflow_id or "" - # Issue #6: Knowledge canonical key 전체 (worker_id 포함) - if intent_result.intent == "OUT_OF_SCOPE": - field_keys = ["worker_id"] - else: - required = self._required_slots_for(workflow_id) - field_keys = list(required) if required else ["worker_id", "stay_expiry_date"] + decisions = _intent_decisions(intent_result) + primary = decisions[0] + + # 복합 Intent이면 각 Workflow의 canonical key를 원문 순서대로 합친다. + field_keys: list[str] = [] + for decision in decisions: + if decision.intent == "OUT_OF_SCOPE": + required = ["worker_id"] + else: + required = self._required_slots_for(decision.workflow_id) + if not required: + required = ["worker_id", "stay_expiry_date"] + for key in required: + if key not in field_keys: + field_keys.append(key) return AnalysisResponse( request_id=request.request_id, outcome="CONTEXT_REQUIRED", context_requirement=ContextRequirement( - detected_intent=intent_result.intent or "UNKNOWN", - confidence=intent_result.confidence, + detected_intent=primary.intent, + workflow_id=primary.workflow_id, + confidence=primary.confidence, + confidence_source=primary.confidence_source, + bert_routing_score=primary.bert_routing_score, + intent_decisions=_wire_intent_decisions(intent_result), target_display_name=_guess_target_display_name(instruction), extracted_slots=dict(intent_result.extracted_slots), required_field_keys=field_keys, @@ -175,8 +279,13 @@ def _run_plan(self, request: AnalysisRequest) -> AnalysisResponse: def _run_analyze(self, request: AnalysisRequest) -> AnalysisResponse: ai = request.analysis_input instruction = ai.instruction - intent_result = self._intent.classify(instruction) - workflow_id = intent_result.workflow_id or "" + intent_result = _planned_intent_result(request) + provider_attempt_count = 0 + if intent_result is None: + # 1.0 호출자 하위호환: 계획 결정을 보내지 않으면 기존처럼 분류한다. + intent_result = self._intent.classify(instruction) + provider_attempt_count = 1 + decisions = _intent_decisions(intent_result) if not ai.workers: return AnalysisResponse( @@ -187,13 +296,14 @@ def _run_analyze(self, request: AnalysisRequest) -> AnalysisResponse: candidates=[], validation_errors=[], versions=_versions(intent_result), - provider_attempt_count=1, + provider_attempt_count=provider_attempt_count, latency_ms=0, ) # MVP: 근로자 1명만 worker = ai.workers[0] slots = _seed_slots_from_worker(worker) + slots.update(intent_result.extracted_slots) # Server가 못 채운 PLAN 요청 키 → HR 질문 후보 hr_keys: list[str] = [] @@ -205,10 +315,13 @@ def _run_analyze(self, request: AnalysisRequest) -> AnalysisResponse: ): hr_keys.append(key) - amb = self._ambiguity.check(workflow_id, slots, instruction) - for key in amb.missing_slots: - if key not in HR_EXCLUDED_SLOTS and key not in slots and key not in hr_keys: - hr_keys.append(key) + for decision in decisions: + if not decision.workflow_id: + continue + amb = self._ambiguity.check(decision.workflow_id, slots, instruction) + for key in amb.missing_slots: + if key not in HR_EXCLUDED_SLOTS and key not in slots and key not in hr_keys: + hr_keys.append(key) if hr_keys: return AnalysisResponse( @@ -219,28 +332,34 @@ def _run_analyze(self, request: AnalysisRequest) -> AnalysisResponse: candidates=[], validation_errors=[], versions=_versions(intent_result), - provider_attempt_count=1, + provider_attempt_count=provider_attempt_count, latency_ms=0, ) - # Intent와 구분되는 Knowledge canonical Workflow ID를 반환 - candidate = AnalysisCandidate( - candidate_ref=f"candidate-{uuid4().hex[:8]}", - worker_ref=worker.worker_ref, - workflow_id=workflow_id, - extracted_slots=slots, - missing_slots=[], - confidence=intent_result.confidence, - ) + # 복합 Intent 각각에 canonical Knowledge Workflow 후보를 만든다. + candidates = [ + AnalysisCandidate( + candidate_ref=f"candidate-{uuid4().hex[:8]}", + worker_ref=worker.worker_ref, + detected_intent=decision.intent, + workflow_id=decision.workflow_id, + extracted_slots=slots, + missing_slots=[], + confidence=decision.confidence, + confidence_source=decision.confidence_source, + bert_routing_score=decision.bert_routing_score, + ) + for decision in decisions + ] return AnalysisResponse( request_id=request.request_id, outcome="REVIEW_REQUIRED", context_requirement=None, questions=[], - candidates=[candidate], + candidates=candidates, validation_errors=[], versions=_versions(intent_result), - provider_attempt_count=1, + provider_attempt_count=provider_attempt_count, latency_ms=0, ) diff --git a/app/api/routes/analyses.py b/app/api/routes/analyses.py index 275ca19..0a708db 100644 --- a/app/api/routes/analyses.py +++ b/app/api/routes/analyses.py @@ -2,10 +2,15 @@ from fastapi import APIRouter, Depends +from app.agents.intent import IntentClassifier from app.agents.pipeline import AnalysisPipeline -from app.api.dependencies import get_analysis_pipeline +from app.api.dependencies import get_analysis_pipeline, get_intent_agent from app.api.openapi import ANALYSES_TAG -from app.api.schemas.analyses import AnalysisRequest, AnalysisResponse +from app.api.schemas.analyses import ( + AnalysisRequest, + AnalysisResponse, + IntentRuntimeStatus, +) from app.api.security import verify_internal_bearer router = APIRouter(prefix="/internal/v1", tags=[ANALYSES_TAG]) @@ -28,3 +33,20 @@ async def analyze( pipeline: AnalysisPipeline = Depends(get_analysis_pipeline), # noqa: B008 ) -> AnalysisResponse: return pipeline.run(request) + + +@router.get( + "/intent/status", + response_model=IntentRuntimeStatus, + summary="Intent 모델 운영 상태", + description=( + "설정 활성화 여부와 lazy-load 이후 BERT/A.X 가용성, Knowledge prompt 버전을 반환. " + "상태 조회만으로 모델을 강제 로드하지 않음." + ), + dependencies=[Depends(verify_internal_bearer)], +) +async def intent_status( + intent_agent: IntentClassifier = Depends(get_intent_agent), # noqa: B008 +) -> IntentRuntimeStatus: + status = intent_agent.runtime_status() + return IntentRuntimeStatus.model_validate(status) diff --git a/app/api/schemas/analyses.py b/app/api/schemas/analyses.py index 602236d..2a59421 100644 --- a/app/api/schemas/analyses.py +++ b/app/api/schemas/analyses.py @@ -8,8 +8,9 @@ AnalysisPhase = Literal["PLAN", "ANALYZE"] AnalysisOutcome = Literal["CONTEXT_REQUIRED", "NEEDS_INFO", "REVIEW_REQUIRED"] +ConfidenceSource = Literal["BERT", "RULES", "UNAVAILABLE"] -DEFAULT_CONTRACT_VERSION = "1.0.0" +DEFAULT_CONTRACT_VERSION = "1.1.0" DEFAULT_KNOWLEDGE_VERSION = "0.2.0" @@ -29,12 +30,37 @@ class WorkerContext(BaseModel): model_config = {"populate_by_name": True} +class IntentDecisionItem(BaseModel): + + detected_intent: str = Field(..., alias="detectedIntent") + workflow_id: str = Field(..., alias="workflowId") + evidence: str | None = None + confidence: float | None = Field(None, ge=0.0, le=1.0) + confidence_source: ConfidenceSource = Field(..., alias="confidenceSource") + bert_routing_score: float | None = Field( + None, alias="bertRoutingScore", ge=0.0, le=1.0 + ) + model_provider: str = Field(..., alias="modelProvider") + model_name: str = Field(..., alias="modelName") + model_version: str = Field(..., alias="modelVersion") + prompt_version: str = Field(..., alias="promptVersion") + + model_config = {"populate_by_name": True} + + # HR 지시 + PLAN/ANALYZE 문맥 (HTTP 최소 페이로드) class AnalysisInput(BaseModel): instruction: str requested_field_keys: list[str] = Field(default_factory=list, alias="requestedFieldKeys") workers: list[WorkerContext] = Field(default_factory=list) + # 단일 Intent 호출자는 PLAN의 대표 결정을 그대로 되돌려 줄 수 있다. + planned_intent: str | None = Field(None, alias="plannedIntent") + planned_workflow_id: str | None = Field(None, alias="plannedWorkflowId") + # 복합 Intent 호출자는 PLAN의 전체 결정을 보존한다. 배열이 단일 필드보다 우선한다. + planned_intent_decisions: list[IntentDecisionItem] = Field( + default_factory=list, alias="plannedIntentDecisions" + ) model_config = {"populate_by_name": True} @@ -62,7 +88,15 @@ class ValidationErrorItem(BaseModel): class ContextRequirement(BaseModel): detected_intent: str = Field(..., alias="detectedIntent") - confidence: float = Field(..., ge=0.0, le=1.0) + workflow_id: str = Field(..., alias="workflowId") + confidence: float | None = Field(None, ge=0.0, le=1.0) + confidence_source: ConfidenceSource = Field(..., alias="confidenceSource") + bert_routing_score: float | None = Field( + None, alias="bertRoutingScore", ge=0.0, le=1.0 + ) + intent_decisions: list[IntentDecisionItem] = Field( + default_factory=list, alias="intentDecisions" + ) target_display_name: str = Field(..., alias="targetDisplayName") extracted_slots: dict[str, str] = Field(default_factory=dict, alias="extractedSlots") required_field_keys: list[str] = Field(..., alias="requiredFieldKeys") @@ -84,10 +118,15 @@ class AnalysisCandidate(BaseModel): candidate_ref: str = Field(..., alias="candidateRef") worker_ref: str = Field(..., alias="workerRef", description="서버 worker_id") + detected_intent: str = Field(..., alias="detectedIntent") workflow_id: str = Field(..., alias="workflowId") extracted_slots: dict[str, str] = Field(default_factory=dict, alias="extractedSlots") missing_slots: list[str] = Field(default_factory=list, alias="missingSlots") - confidence: float = Field(..., ge=0.0, le=1.0) + confidence: float | None = Field(None, ge=0.0, le=1.0) + confidence_source: ConfidenceSource = Field(..., alias="confidenceSource") + bert_routing_score: float | None = Field( + None, alias="bertRoutingScore", ge=0.0, le=1.0 + ) model_config = {"populate_by_name": True} @@ -99,7 +138,7 @@ class AnalysisVersions(BaseModel): model_provider: str = Field(..., alias="modelProvider") model_name: str = Field(..., alias="modelName") model_version: str = Field(..., alias="modelVersion") - prompt_version: str = Field("prompt-1", alias="promptVersion") + prompt_version: str = Field("not-applicable", alias="promptVersion") context_pack_version: str = Field(DEFAULT_KNOWLEDGE_VERSION, alias="contextPackVersion") workflow_catalog_version: str = Field( DEFAULT_KNOWLEDGE_VERSION, alias="workflowCatalogVersion" @@ -125,3 +164,17 @@ class AnalysisResponse(BaseModel): latency_ms: int = Field(0, alias="latencyMs") model_config = {"populate_by_name": True, "by_alias": True} + + +# Intent 모델 운영 상태 — 조회 자체는 모델을 강제로 로드하지 않는다. +class IntentRuntimeStatus(BaseModel): + + intent_model_enabled: bool = Field(..., alias="intentModelEnabled") + ax_enabled: bool = Field(..., alias="axEnabled") + initialized: bool + bert_available: bool = Field(..., alias="bertAvailable") + ax_available: bool = Field(..., alias="axAvailable") + degraded: bool + prompt_version: str = Field(..., alias="promptVersion") + + model_config = {"populate_by_name": True, "by_alias": True} diff --git a/app/core/config.py b/app/core/config.py index 438c775..322f47e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -47,8 +47,11 @@ class Settings(BaseSettings): # Intent HF — true면 BERT(+선택 A.X) 분류, false면 EXPIRY_RENEWAL 고정 intent_model_enabled: bool = False intent_bert_model_dir: str = "fowoco/klue-roberta-base-intent-classifier" + intent_bert_model_revision: str | None = None intent_ax_base_model: str = "skt/A.X-4.0-Light" + intent_ax_base_revision: str | None = None intent_ax_adapter_path: str = "fowoco/ax-intent-qlora" + intent_ax_adapter_revision: str | None = None intent_enable_ax: bool = False intent_device: str = "cpu" intent_margin_threshold: float = 0.76 diff --git a/docs/analyses-contract.md b/docs/analyses-contract.md index 0c24c39..62a1a2c 100644 --- a/docs/analyses-contract.md +++ b/docs/analyses-contract.md @@ -1,8 +1,8 @@ # Analyses Runtime 계약 (AI 소유) -Server `docs/ai-runtime-contract.md` + `AiRuntimeHttpRequest` (fowoco/server main)과 맞춘다. -**HTTP 와이어**는 최소 페이로드다. `attemptId` / version / deadline / `extractedSlots` / -`workflowConstraints` 는 Server 내부 `AiAnalysisRequest`에만 있고 **요청 JSON에 실리지 않는다**. +Server `docs/ai-runtime-contract.md` + `AiRuntimeHttpRequest` (fowoco/server main)과 맞춘다. +계약 버전은 **1.1.0**이다. `attemptId` / deadline / `workflowConstraints`는 Server 내부에만 +두며, PLAN에서 확정한 Intent 결정은 ANALYZE 요청에 되돌려 보내 재분류를 막는다. ## Endpoint @@ -13,9 +13,9 @@ POST /internal/v1/analyses ## 흐름 ```text -PLAN → CONTEXT_REQUIRED (requiredFieldKeys) +PLAN → Intent/A.X 1회 → CONTEXT_REQUIRED (intentDecisions + requiredFieldKeys) → Server DB 조회 -ANALYZE → NEEDS_INFO (questions) | REVIEW_REQUIRED (candidates) +ANALYZE → PLAN 결정 재사용(모델 0회) → NEEDS_INFO (questions) | REVIEW_REQUIRED (candidates) ``` `CONTEXT_REQUIRED` / `NEEDS_INFO` / `REVIEW_REQUIRED` 는 모두 **성공 outcome** 이다 (`FAILED` 아님). @@ -51,7 +51,24 @@ ANALYZE → NEEDS_INFO (questions) | REVIEW_REQUIRED (candidates) "outcome": "CONTEXT_REQUIRED", "contextRequirement": { "detectedIntent": "EXPIRY_RENEWAL", - "confidence": 0.94, + "workflowId": "WF-STY-001", + "confidence": null, + "confidenceSource": "UNAVAILABLE", + "bertRoutingScore": 0.3088, + "intentDecisions": [ + { + "detectedIntent": "EXPIRY_RENEWAL", + "workflowId": "WF-STY-001", + "evidence": "체류연장 준비해줘", + "confidence": null, + "confidenceSource": "UNAVAILABLE", + "bertRoutingScore": 0.3088, + "modelProvider": "huggingface", + "modelName": "skt/A.X-4.0-Light", + "modelVersion": "AX", + "promptVersion": "knowledge-25e778ad" + } + ], "targetDisplayName": "응웬반안", "extractedSlots": {}, "requiredFieldKeys": ["worker_id", "stay_expiry_date"] @@ -69,7 +86,10 @@ ANALYZE → NEEDS_INFO (questions) | REVIEW_REQUIRED (candidates) |---|---| | `requiredFieldKeys` | 비어 있으면 Server 거부. Knowledge canonical key만 (`worker_id` 포함) | | `questions` / `candidates` | 비움 | -| `confidence` | 0.0 ~ 1.0 | +| `confidence` | BERT/RULES는 0.0~1.0. A.X는 score가 없으므로 `null` | +| `confidenceSource` | `BERT`, `RULES`, `UNAVAILABLE` 중 하나 | +| `bertRoutingScore` | A.X 선택 전 라우팅 참고값. A.X confidence로 해석하면 안 됨 | +| `intentDecisions` | 복합 Intent를 원문 순서대로 보존. 각 항목은 canonical `workflowId` 포함 | ## 3) ANALYZE 요청 (Server → AI) @@ -79,6 +99,22 @@ ANALYZE → NEEDS_INFO (questions) | REVIEW_REQUIRED (candidates) "phase": "ANALYZE", "analysisInput": { "instruction": "응웬반안 체류연장 준비해줘", + "plannedIntent": "EXPIRY_RENEWAL", + "plannedWorkflowId": "WF-STY-001", + "plannedIntentDecisions": [ + { + "detectedIntent": "EXPIRY_RENEWAL", + "workflowId": "WF-STY-001", + "evidence": "체류연장 준비해줘", + "confidence": null, + "confidenceSource": "UNAVAILABLE", + "bertRoutingScore": 0.3088, + "modelProvider": "huggingface", + "modelName": "skt/A.X-4.0-Light", + "modelVersion": "AX", + "promptVersion": "knowledge-25e778ad" + } + ], "requestedFieldKeys": ["worker_id", "stay_expiry_date"], "workers": [ { @@ -97,12 +133,14 @@ ANALYZE → NEEDS_INFO (questions) | REVIEW_REQUIRED (candidates) |---|---| | `requestedFieldKeys` | PLAN에서 Agent가 요청한 **전체** key (DB 미조회여도 목록 유지) | | `workers[].requestedFields` | Server가 **실제로 찾은 값만** | +| `plannedIntentDecisions` | PLAN 응답의 `intentDecisions`를 변경 없이 전달. 복합 Intent 권장 계약 | +| `plannedIntent` / `plannedWorkflowId` | 단일 Intent 호출자의 최소 재사용 계약 | | DB 미조회 키 | `requestedFieldKeys − requestedFields.keys` → HR 질문 후보 | | MVP | Worker **1명** | | HTTP에 안 실림 | `extractedSlots`, `workflowConstraints`, attemptId, versions, deadline | -> 이슈 댓글의 ANALYZE `extractedSlots` 와이어 추가는 **최종 HTTP 계약에서 제외**됨 -> (`AiRuntimeHttpRequest` 주석·직렬화 기준). +`plannedIntentDecisions`가 있으면 배열이 단일 필드보다 우선한다. 두 계약이 모두 없을 때만 +1.0 하위호환을 위해 Intent 모델을 다시 호출하며, 이 경로는 Server 전환 후 제거할 수 있다. ## 4) ANALYZE 응답 @@ -124,6 +162,7 @@ ANALYZE → NEEDS_INFO (questions) | REVIEW_REQUIRED (candidates) { "candidateRef": "candidate-1", "workerRef": "30000000-0000-0000-0000-000000000001", + "detectedIntent": "EXPIRY_RENEWAL", "workflowId": "WF-STY-001", "extractedSlots": { "worker_id": "30000000-0000-0000-0000-000000000001", @@ -131,7 +170,9 @@ ANALYZE → NEEDS_INFO (questions) | REVIEW_REQUIRED (candidates) "full_name": "NGUYEN VAN AN" }, "missingSlots": [], - "confidence": 0.92 + "confidence": null, + "confidenceSource": "UNAVAILABLE", + "bertRoutingScore": 0.3088 } ``` @@ -144,7 +185,7 @@ ANALYZE → NEEDS_INFO (questions) | REVIEW_REQUIRED (candidates) Server가 내부 요청의 `contractVersion` / `requiredKnowledgeVersion` 과 응답 `versions.contractVersion` / `versions.workflowCatalogVersion` 을 대조한다. -HTTP 요청에 version이 없어도 AI는 기본값 **`1.0.0` / `0.2.0`** 을 맞춰야 한다. +HTTP 요청에 version이 없어도 AI는 기본값 **`1.1.0` / `0.2.0`** 을 맞춰야 한다. --- @@ -156,10 +197,13 @@ HTTP 요청에 version이 없어도 AI는 기본값 **`1.0.0` / `0.2.0`** 을 | `CONTEXT_REQUIRED` | 있음 | **반영** | | `questions` | NEEDS_INFO | **반영** | | ANALYZE `requestedFieldKeys` | 있음 | **반영** | +| PLAN 결정 재사용 | plannedIntent(s) | **반영** (ANALYZE providerAttemptCount=0) | +| 복합 Intent | intentDecisions[] | **반영** (Intent별 candidate) | +| A.X confidence | score 없음 | **null + BERT routing score 분리** | | workers 최소 필드 | workerRef + requestedFields | **반영** (추가 필드는 선택) | | attemptId 등 | HTTP 미전송 | **요청에서 제거** | | 슬롯 기준 | Knowledge | Ambiguity/Workflow catalog | -| versions | 응답 필수 | `1.0.0` / `0.2.0` 고정 | +| versions | 응답 필수 | `1.1.0` / `0.2.0` 고정 | ## Intent 분류기 @@ -172,7 +216,13 @@ HTTP 요청에 version이 없어도 AI는 기본값 **`1.0.0` / `0.2.0`** 을 A.X까지: `pip install -e ".[intent-ax]"` (Linux/CUDA; Windows에선 `bitsandbytes` 실패 흔함). `.env`에 `FOWOCO_HF_TOKEN` 또는 `HF_TOKEN`, `FOWOCO_INTENT_BERT_MODEL_DIR=fowoco/klue-roberta-base-intent-classifier`. -로컬 CPU는 `FOWOCO_INTENT_ENABLE_AX=false` 권장. `.env.example`은 두지 않음(로컬 `.env`만). +로컬 CPU는 `FOWOCO_INTENT_ENABLE_AX=false` 권장. 운영은 실제 추론 장치에 맞춰 +`FOWOCO_INTENT_DEVICE`를 설정하고 private 모델 토큰은 Kubernetes Secret으로만 주입한다. +BERT/Base/Adapter의 `*_REVISION`은 배포 전에 immutable Hugging Face commit SHA로 고정한다. + +`GET /internal/v1/intent/status`에서 설정 활성화, lazy-load 완료 여부, BERT/A.X 가용성과 +`promptVersion`을 확인한다. 상태 조회는 모델을 강제로 로드하지 않으므로 배포 smoke PLAN 후 +`axAvailable=true`, `promptVersion=knowledge-25e778ad`를 확인한다. ## Fixtures diff --git a/examples/analyses/request_analyze.json b/examples/analyses/request_analyze.json index 0a061a8..4364504 100644 --- a/examples/analyses/request_analyze.json +++ b/examples/analyses/request_analyze.json @@ -3,6 +3,22 @@ "phase": "ANALYZE", "analysisInput": { "instruction": "응웬반안 체류연장 준비해줘", + "plannedIntent": "EXPIRY_RENEWAL", + "plannedWorkflowId": "WF-STY-001", + "plannedIntentDecisions": [ + { + "detectedIntent": "EXPIRY_RENEWAL", + "workflowId": "WF-STY-001", + "evidence": "체류연장 준비해줘", + "confidence": null, + "confidenceSource": "UNAVAILABLE", + "bertRoutingScore": 0.3088, + "modelProvider": "huggingface", + "modelName": "skt/A.X-4.0-Light", + "modelVersion": "AX", + "promptVersion": "knowledge-25e778ad" + } + ], "requestedFieldKeys": [ "worker_id", "stay_expiry_date" diff --git a/examples/analyses/response_context_required.json b/examples/analyses/response_context_required.json index fe68250..a1279f2 100644 --- a/examples/analyses/response_context_required.json +++ b/examples/analyses/response_context_required.json @@ -3,7 +3,24 @@ "outcome": "CONTEXT_REQUIRED", "contextRequirement": { "detectedIntent": "EXPIRY_RENEWAL", - "confidence": 0.94, + "workflowId": "WF-STY-001", + "confidence": null, + "confidenceSource": "UNAVAILABLE", + "bertRoutingScore": 0.3088, + "intentDecisions": [ + { + "detectedIntent": "EXPIRY_RENEWAL", + "workflowId": "WF-STY-001", + "evidence": "체류연장 준비해줘", + "confidence": null, + "confidenceSource": "UNAVAILABLE", + "bertRoutingScore": 0.3088, + "modelProvider": "huggingface", + "modelName": "skt/A.X-4.0-Light", + "modelVersion": "AX", + "promptVersion": "knowledge-25e778ad" + } + ], "targetDisplayName": "응웬반안", "extractedSlots": {}, "requiredFieldKeys": [ @@ -16,13 +33,13 @@ "validationErrors": [], "versions": { "agentVersion": "0.1.0", - "modelProvider": "stub", - "modelName": "stub", - "modelVersion": "stub", - "promptVersion": "prompt-1", + "modelProvider": "huggingface", + "modelName": "skt/A.X-4.0-Light", + "modelVersion": "AX", + "promptVersion": "knowledge-25e778ad", "contextPackVersion": "0.2.0", "workflowCatalogVersion": "0.2.0", - "contractVersion": "1.0.0" + "contractVersion": "1.1.0" }, "providerAttemptCount": 1, "latencyMs": 120 diff --git a/examples/analyses/response_needs_info.json b/examples/analyses/response_needs_info.json index 01eae79..11e46a7 100644 --- a/examples/analyses/response_needs_info.json +++ b/examples/analyses/response_needs_info.json @@ -12,14 +12,14 @@ "validationErrors": [], "versions": { "agentVersion": "0.1.0", - "modelProvider": "stub", - "modelName": "stub", - "modelVersion": "stub", - "promptVersion": "prompt-1", + "modelProvider": "huggingface", + "modelName": "skt/A.X-4.0-Light", + "modelVersion": "AX", + "promptVersion": "knowledge-25e778ad", "contextPackVersion": "0.2.0", "workflowCatalogVersion": "0.2.0", - "contractVersion": "1.0.0" + "contractVersion": "1.1.0" }, - "providerAttemptCount": 1, + "providerAttemptCount": 0, "latencyMs": 180 } diff --git a/examples/analyses/response_review_required.json b/examples/analyses/response_review_required.json index c23dbff..9810d18 100644 --- a/examples/analyses/response_review_required.json +++ b/examples/analyses/response_review_required.json @@ -7,6 +7,7 @@ { "candidateRef": "candidate-1", "workerRef": "30000000-0000-0000-0000-000000000001", + "detectedIntent": "EXPIRY_RENEWAL", "workflowId": "WF-STY-001", "extractedSlots": { "worker_id": "30000000-0000-0000-0000-000000000001", @@ -14,20 +15,22 @@ "full_name": "NGUYEN VAN AN" }, "missingSlots": [], - "confidence": 0.92 + "confidence": null, + "confidenceSource": "UNAVAILABLE", + "bertRoutingScore": 0.3088 } ], "validationErrors": [], "versions": { "agentVersion": "0.1.0", - "modelProvider": "stub", - "modelName": "stub", - "modelVersion": "stub", - "promptVersion": "prompt-1", + "modelProvider": "huggingface", + "modelName": "skt/A.X-4.0-Light", + "modelVersion": "AX", + "promptVersion": "knowledge-25e778ad", "contextPackVersion": "0.2.0", "workflowCatalogVersion": "0.2.0", - "contractVersion": "1.0.0" + "contractVersion": "1.1.0" }, - "providerAttemptCount": 1, + "providerAttemptCount": 0, "latencyMs": 245 } diff --git a/pyproject.toml b/pyproject.toml index c80a743..48ca86e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ include = ["app*"] [tool.setuptools.package-data] "app.documents.hwp5.templates" = ["*.json", "*.hwp"] "app.documents.hwpx.templates" = ["*.hwpx"] +"app.agents.intent" = ["prompts/*.txt"] "app.agents.language.resources" = ["*.json", "*.sha256", "prompts/*.md"] [tool.pytest.ini_options] diff --git a/tests/agents/test_analysis_pipeline.py b/tests/agents/test_analysis_pipeline.py index b514fee..d3df36b 100644 --- a/tests/agents/test_analysis_pipeline.py +++ b/tests/agents/test_analysis_pipeline.py @@ -2,17 +2,29 @@ from uuid import uuid4 -from app.agents.intent.service import IntentResult +from app.agents.intent.service import IntentDecision, IntentResult from app.agents.pipeline import AnalysisPipeline from app.api.schemas.analyses import AnalysisInput, AnalysisRequest, WorkerContext # IntentClassifier Protocol용 고정 분류기 class _FakeIntent: - def __init__(self, *, intent: str, confidence: float = 0.9, workflow_id: str = "") -> None: + def __init__( + self, + *, + intent: str, + confidence: float | None = 0.9, + workflow_id: str = "", + decisions: list[IntentDecision] | None = None, + ) -> None: self.intent = intent self.confidence = confidence self.workflow_id = workflow_id + self.decisions = decisions or [] + self.calls = 0 + + def runtime_status(self) -> dict[str, object]: + return {} def classify( self, @@ -21,6 +33,7 @@ def classify( workflow_constraints: list[str] | None = None, ) -> IntentResult: del instruction, workflow_constraints + self.calls += 1 return IntentResult( intent=self.intent, confidence=self.confidence, @@ -29,6 +42,10 @@ def classify( model_name="fake-intent", model_version="1", extracted_slots={}, + prompt_version="test-prompt-v1", + confidence_source="BERT", + bert_routing_score=self.confidence, + decisions=self.decisions, ) @@ -46,9 +63,13 @@ def test_plan_returns_context_required_for_expiry() -> None: assert res.outcome == "CONTEXT_REQUIRED" assert res.context_requirement is not None assert res.context_requirement.detected_intent == "EXPIRY_RENEWAL" + assert res.context_requirement.workflow_id == "WF-STY-001" + assert res.context_requirement.confidence_source == "BERT" + assert res.context_requirement.intent_decisions[0].workflow_id == "WF-STY-001" assert res.versions.model_provider == "test" assert res.versions.model_name == "fake-intent" assert res.versions.model_version == "1" + assert res.versions.prompt_version == "test-prompt-v1" assert "worker_id" in res.context_requirement.required_field_keys assert "passport_status" in res.context_requirement.required_field_keys assert "arc_status" in res.context_requirement.required_field_keys @@ -154,3 +175,123 @@ def test_analyze_filled_slots_review_required() -> None: assert len(res.candidates) == 1 assert res.candidates[0].missing_slots == [] assert res.candidates[0].workflow_id == "WF-STY-001" + + +def test_analyze_reuses_plan_decisions_without_classifying_again() -> None: + intent = _FakeIntent( + intent="EXPIRY_RENEWAL", confidence=0.91, workflow_id="WF-STY-001" + ) + pipe = AnalysisPipeline(intent_agent=intent) + plan = pipe.run( + AnalysisRequest( + requestId=str(uuid4()), + phase="PLAN", + analysisInput=AnalysisInput(instruction="체류연장 준비해줘"), + ) + ) + assert plan.context_requirement is not None + + analyze = pipe.run( + AnalysisRequest( + requestId=str(uuid4()), + phase="ANALYZE", + analysisInput=AnalysisInput( + instruction="체류연장 준비해줘", + requestedFieldKeys=plan.context_requirement.required_field_keys, + plannedIntentDecisions=plan.context_requirement.intent_decisions, + workers=[ + WorkerContext( + workerRef="worker-1", + requestedFields={ + "worker_id": "worker-1", + "stay_expiry_date": "2026-12-31", + }, + ) + ], + ), + ) + ) + + assert intent.calls == 1 + assert analyze.provider_attempt_count == 0 + assert analyze.outcome == "REVIEW_REQUIRED" + assert analyze.candidates[0].detected_intent == "EXPIRY_RENEWAL" + + +def test_multi_intent_unions_plan_fields_and_builds_one_candidate_per_intent() -> None: + decisions = [ + IntentDecision( + intent="EXPIRY_RENEWAL", + workflow_id="WF-STY-001", + confidence=None, + confidence_source="UNAVAILABLE", + bert_routing_score=0.31, + evidence="체류연장 준비하고", + ), + IntentDecision( + intent="PAYROLL_EXPLANATION", + workflow_id="WF-PAY-001", + confidence=None, + confidence_source="UNAVAILABLE", + bert_routing_score=0.22, + evidence="급여도 확인해줘", + ), + ] + intent = _FakeIntent( + intent="EXPIRY_RENEWAL", + confidence=None, + workflow_id="WF-STY-001", + decisions=decisions, + ) + pipe = AnalysisPipeline(intent_agent=intent) + plan = pipe.run( + AnalysisRequest( + requestId=str(uuid4()), + phase="PLAN", + analysisInput=AnalysisInput( + instruction="체류연장 준비하고 급여도 확인해줘" + ), + ) + ) + assert plan.context_requirement is not None + assert plan.context_requirement.required_field_keys == [ + "worker_id", + "stay_expiry_date", + "passport_status", + "arc_status", + "pay_period", + ] + + analyze = pipe.run( + AnalysisRequest( + requestId=str(uuid4()), + phase="ANALYZE", + analysisInput=AnalysisInput( + instruction="체류연장 준비하고 급여도 확인해줘", + requestedFieldKeys=plan.context_requirement.required_field_keys, + plannedIntentDecisions=plan.context_requirement.intent_decisions, + workers=[ + WorkerContext( + workerRef="worker-1", + requestedFields={ + "worker_id": "worker-1", + "stay_expiry_date": "2026-12-31", + "pay_period": "2026-08", + }, + ) + ], + ), + ) + ) + + assert intent.calls == 1 + assert analyze.provider_attempt_count == 0 + assert [candidate.detected_intent for candidate in analyze.candidates] == [ + "EXPIRY_RENEWAL", + "PAYROLL_EXPLANATION", + ] + assert [candidate.workflow_id for candidate in analyze.candidates] == [ + "WF-STY-001", + "WF-PAY-001", + ] + assert all(candidate.confidence is None for candidate in analyze.candidates) diff --git a/tests/agents/test_ax_intent_model.py b/tests/agents/test_ax_intent_model.py new file mode 100644 index 0000000..67f1098 --- /dev/null +++ b/tests/agents/test_ax_intent_model.py @@ -0,0 +1,134 @@ +import hashlib + +import pytest + +from app.agents.intent.models_hf import AxIntentModel, _validate_ax_intents +from app.agents.intent.prompts import ( + AX_INTENT_PROMPT_SHA256, + AX_INTENT_PROMPT_VERSION, + AX_INTENT_SYSTEM_PROMPT, +) + + +class _NoGrad: + def __enter__(self) -> None: + return None + + def __exit__(self, *_args: object) -> None: + return None + + +class _FakeTorch: + @staticmethod + def no_grad() -> _NoGrad: + return _NoGrad() + + +class _FakeInputIds: + shape = (1, 3) + + +class _FakeInputs(dict[str, object]): + def to(self, _device: str) -> "_FakeInputs": + return self + + +class _FakeTokenizer: + def __init__(self, decoded: str) -> None: + self.decoded = decoded + self.messages: list[dict[str, str]] = [] + + def apply_chat_template( + self, messages: list[dict[str, str]], **_kwargs: object + ) -> _FakeInputs: + self.messages = messages + return _FakeInputs(input_ids=_FakeInputIds()) + + def decode(self, _tokens: object, **_kwargs: object) -> str: + return self.decoded + + +class _FakeModel: + device = "cpu" + + def generate(self, **_kwargs: object) -> list[list[int]]: + return [[1, 2, 3, 4]] + + +def _model_with_output(decoded: str) -> AxIntentModel: + model = AxIntentModel.__new__(AxIntentModel) + model._torch = _FakeTorch() + model.max_new_tokens = 96 + model.tokenizer = _FakeTokenizer(decoded) + model.model = _FakeModel() + return model + + +def test_ax_prompt_matches_pinned_knowledge_source() -> None: + digest = hashlib.sha256(AX_INTENT_SYSTEM_PROMPT.encode()).hexdigest() + assert digest == AX_INTENT_PROMPT_SHA256 + assert AxIntentModel._SYSTEM_PROMPT == AX_INTENT_SYSTEM_PROMPT + assert AxIntentModel.prompt_version == AX_INTENT_PROMPT_VERSION + + +def test_ax_predict_sends_knowledge_prompt_and_preserves_intent_order() -> None: + instruction = "체류연장 준비하고 급여도 확인해줘" + model = _model_with_output( + '{"intents": [' + '{"intent": "EXPIRY_RENEWAL", "evidence": "체류연장"},' + '{"intent": "PAYROLL_EXPLANATION", "evidence": "급여도 확인"}' + "]}" + ) + + result = model.predict(instruction) + + assert result == [ + {"intent": "EXPIRY_RENEWAL", "evidence": "체류연장"}, + {"intent": "PAYROLL_EXPLANATION", "evidence": "급여도 확인"}, + ] + assert model.tokenizer.messages == [ + {"role": "system", "content": AX_INTENT_SYSTEM_PROMPT}, + {"role": "user", "content": instruction}, + ] + + +@pytest.mark.parametrize( + ("items", "instruction", "message"), + [ + ([{"intent": "RENEWAL", "evidence": "연장"}], "연장", "unsupported intent"), + ([{"intent": "OUT_OF_SCOPE"}], "완료했습니다", "evidence field is required"), + ( + [{"intent": "EXPIRY_RENEWAL", "evidence": "원문에 없음"}], + "체류연장", + "exact input substring", + ), + ( + [ + {"intent": "OUT_OF_SCOPE", "evidence": None}, + {"intent": "DOCUMENT_REQUEST", "evidence": "서류"}, + ], + "서류", + "cannot be combined", + ), + ( + [ + {"intent": "DOCUMENT_REQUEST", "evidence": "서류"}, + {"intent": "DOCUMENT_REQUEST", "evidence": "요청"}, + ], + "서류 요청", + "duplicate intent", + ), + ], +) +def test_ax_output_contract_rejects_invalid_items( + items: object, instruction: str, message: str +) -> None: + with pytest.raises(ValueError, match=message): + _validate_ax_intents(items, instruction) + + +def test_ax_predict_rejects_non_json_output() -> None: + model = _model_with_output("설명문만 반환했습니다") + + with pytest.raises(ValueError, match="could not be parsed as JSON"): + model.predict("체류연장") diff --git a/tests/agents/test_intent_hybrid.py b/tests/agents/test_intent_hybrid.py index dec1b0a..52e8b2c 100644 --- a/tests/agents/test_intent_hybrid.py +++ b/tests/agents/test_intent_hybrid.py @@ -1,7 +1,8 @@ # HF Intent 에이전트·대표 Intent 선택 단위 테스트 from app.agents.intent.guardrail import HRRoutingGuardrail -from app.agents.intent.hybrid import HybridIntentPrediction +from app.agents.intent.hybrid import HybridIntentPipeline, HybridIntentPrediction +from app.agents.intent.prompts import AX_INTENT_PROMPT_VERSION from app.agents.intent.service import ( FixedExpiryRenewalIntentAgent, HybridHfIntentAgent, @@ -56,6 +57,38 @@ def predict(self, instruction: str) -> HybridIntentPrediction: assert result.model_provider == "huggingface" assert result.model_name == get_settings().intent_bert_model_dir assert result.model_version == "BERT" + assert result.prompt_version == "not-applicable" + assert result.confidence_source == "BERT" + assert result.bert_routing_score == 0.93 + + +# A.X는 Knowledge prompt의 발화문 등장 순서를 대표 Intent에도 유지 +def test_hybrid_agent_preserves_ax_intent_order() -> None: + class _FakePipe: + def predict(self, instruction: str) -> HybridIntentPrediction: + del instruction + return HybridIntentPrediction( + intents=["DOCUMENT_REQUEST", "EXPIRY_RENEWAL"], + scores={"DOCUMENT_REQUEST": 0.2, "EXPIRY_RENEWAL": 0.95}, + evidence={"DOCUMENT_REQUEST": "서류를 요청해"}, + selected_model="AX", + prompt_version=AX_INTENT_PROMPT_VERSION, + ) + + agent = HybridHfIntentAgent(pipeline=_FakePipe()) + result = agent.classify("서류를 요청해. 체류연장도 준비해줘") + + assert result.intent == "DOCUMENT_REQUEST" + assert result.workflow_id == "WF-DOC-001" + assert result.confidence is None + assert result.confidence_source == "UNAVAILABLE" + assert result.bert_routing_score == 0.2 + assert [decision.intent for decision in result.decisions] == [ + "DOCUMENT_REQUEST", + "EXPIRY_RENEWAL", + ] + assert all(decision.confidence is None for decision in result.decisions) + assert result.prompt_version == AX_INTENT_PROMPT_VERSION # INTENT_MODEL_ENABLED=true → HybridHfIntentAgent @@ -69,6 +102,54 @@ def test_build_intent_agent_enabled_returns_hybrid(monkeypatch) -> None: get_settings.cache_clear() +def test_hybrid_runtime_status_reports_loaded_ax_and_prompt(monkeypatch) -> None: + monkeypatch.setenv("FOWOCO_INTENT_ENABLE_AX", "true") + get_settings.cache_clear() + try: + pipeline = type( + "LoadedPipeline", + (), + {"bert": object(), "ax": object(), "ax_enabled": True}, + )() + status = HybridHfIntentAgent(pipeline=pipeline).runtime_status() + + assert status == { + "intentModelEnabled": True, + "axEnabled": True, + "initialized": True, + "bertAvailable": True, + "axAvailable": True, + "degraded": False, + "promptVersion": AX_INTENT_PROMPT_VERSION, + } + finally: + get_settings.cache_clear() + + +def test_hybrid_loader_forwards_pinned_model_revisions(monkeypatch) -> None: + captured: dict[str, object] = {} + + class _FakeHybridPipeline: + def __init__(self, **kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setenv("FOWOCO_INTENT_BERT_MODEL_REVISION", "bert-commit") + monkeypatch.setenv("FOWOCO_INTENT_AX_BASE_REVISION", "base-commit") + monkeypatch.setenv("FOWOCO_INTENT_AX_ADAPTER_REVISION", "adapter-commit") + monkeypatch.setattr( + "app.agents.intent.hybrid.HybridIntentPipeline", _FakeHybridPipeline + ) + get_settings.cache_clear() + try: + agent = HybridHfIntentAgent() + assert agent._ensure_pipeline() is not None + assert captured["bert_model_revision"] == "bert-commit" + assert captured["ax_base_revision"] == "base-commit" + assert captured["ax_adapter_revision"] == "adapter-commit" + finally: + get_settings.cache_clear() + + # 파이프라인 로드 실패 시 재갱신 고정 폴백 def test_hybrid_load_failure_falls_back_to_fixed(monkeypatch) -> None: def _boom(*_args, **_kwargs): # noqa: ANN002, ANN003 @@ -106,3 +187,58 @@ def test_guardrail_routes_on_document_keyword() -> None: ) assert out.should_route is True assert out.category == "Rule_Document" + + +def test_pipeline_calls_ax_when_guardrail_routes() -> None: + class _Bert: + device = "cpu" + + def predict(self, _instruction: str) -> tuple[dict[str, float], float, list[str]]: + return {"DOCUMENT_REQUEST": 0.8}, 0.9, ["DOCUMENT_REQUEST"] + + class _Guardrail: + def should_route_to_ax(self, *_args: object) -> object: + return type("Route", (), {"should_route": True})() + + class _Ax: + prompt_version = AX_INTENT_PROMPT_VERSION + + def predict(self, _instruction: str) -> list[dict[str, str]]: + return [{"intent": "DOCUMENT_REQUEST", "evidence": "서류 챙겨줘"}] + + pipeline = HybridIntentPipeline.__new__(HybridIntentPipeline) + pipeline.bert = _Bert() + pipeline.guardrail = _Guardrail() + pipeline.ax = _Ax() + pipeline.ax_enabled = True + + prediction = pipeline.predict("서류 챙겨줘") + + assert prediction.selected_model == "AX" + assert prediction.intents == ["DOCUMENT_REQUEST"] + assert prediction.evidence == {"DOCUMENT_REQUEST": "서류 챙겨줘"} + assert prediction.prompt_version == AX_INTENT_PROMPT_VERSION + + +def test_pipeline_marks_fallback_when_ax_enabled_but_unavailable() -> None: + class _Bert: + device = "cpu" + + def predict(self, _instruction: str) -> tuple[dict[str, float], float, list[str]]: + return {"DOCUMENT_REQUEST": 0.8}, 0.9, ["DOCUMENT_REQUEST"] + + class _Guardrail: + def should_route_to_ax(self, *_args: object) -> object: + return type("Route", (), {"should_route": True})() + + pipeline = HybridIntentPipeline.__new__(HybridIntentPipeline) + pipeline.bert = _Bert() + pipeline.guardrail = _Guardrail() + pipeline.ax = None + pipeline.ax_enabled = True + + prediction = pipeline.predict("서류 챙겨줘") + + assert prediction.selected_model == "BERT_FALLBACK" + assert prediction.degraded is True + assert prediction.prompt_version == AX_INTENT_PROMPT_VERSION diff --git a/tests/api/test_analyses_endpoint.py b/tests/api/test_analyses_endpoint.py index b35bc32..20668cb 100644 --- a/tests/api/test_analyses_endpoint.py +++ b/tests/api/test_analyses_endpoint.py @@ -28,6 +28,8 @@ def _analyze_body( "phase": "ANALYZE", "analysisInput": { "instruction": instruction, + "plannedIntent": "EXPIRY_RENEWAL", + "plannedWorkflowId": "WF-STY-001", "requestedFieldKeys": requested_field_keys or ["worker_id", "stay_expiry_date"], "workers": [ @@ -57,14 +59,19 @@ async def test_plan_returns_context_required() -> None: assert data["questions"] == [] ctx = data["contextRequirement"] assert ctx["detectedIntent"] == "EXPIRY_RENEWAL" + assert ctx["workflowId"] == "WF-STY-001" + assert ctx["confidenceSource"] == "RULES" + assert ctx["bertRoutingScore"] is None + assert ctx["intentDecisions"][0]["workflowId"] == "WF-STY-001" assert ctx["targetDisplayName"] == "응웬반안" assert "stay_expiry_date" in ctx["requiredFieldKeys"] assert "worker_id" in ctx["requiredFieldKeys"] - assert data["versions"]["contractVersion"] == "1.0.0" + assert data["versions"]["contractVersion"] == "1.1.0" assert data["versions"]["workflowCatalogVersion"] == "0.2.0" assert data["versions"]["modelProvider"] != "stub" assert data["versions"]["modelName"] != "stub" assert data["versions"]["modelVersion"] != "stub" + assert data["versions"]["promptVersion"] == "not-applicable" assert "attemptId" not in data @@ -81,11 +88,46 @@ async def test_analyze_returns_review_required_when_slots_filled() -> None: assert len(data["candidates"]) == 1 candidate = data["candidates"][0] assert candidate["workerRef"] == "30000000-0000-0000-0000-000000000001" + assert candidate["detectedIntent"] == "EXPIRY_RENEWAL" assert candidate["workflowId"] == "WF-STY-001" + assert candidate["confidence"] is None + assert candidate["confidenceSource"] == "UNAVAILABLE" assert candidate["extractedSlots"]["stay_expiry_date"] == "2026-12-31" assert candidate["extractedSlots"]["worker_id"] == ( "30000000-0000-0000-0000-000000000001" ) + assert data["providerAttemptCount"] == 0 + + +@pytest.mark.asyncio +async def test_analyze_reuses_full_ax_plan_contract() -> None: + body = _analyze_body() + body["analysisInput"].pop("plannedIntent") + body["analysisInput"].pop("plannedWorkflowId") + body["analysisInput"]["plannedIntentDecisions"] = [ + { + "detectedIntent": "EXPIRY_RENEWAL", + "workflowId": "WF-STY-001", + "evidence": "체류연장 준비해줘", + "confidence": None, + "confidenceSource": "UNAVAILABLE", + "bertRoutingScore": 0.3088, + "modelProvider": "huggingface", + "modelName": "skt/A.X-4.0-Light", + "modelVersion": "AX", + "promptVersion": "knowledge-25e778ad", + } + ] + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post(ANALYSES_PATH, json=body) + + assert resp.status_code == 200 + data = resp.json() + assert data["providerAttemptCount"] == 0 + assert data["versions"]["modelVersion"] == "AX" + assert data["versions"]["promptVersion"] == "knowledge-25e778ad" + assert data["candidates"][0]["confidence"] is None + assert data["candidates"][0]["bertRoutingScore"] == 0.3088 @pytest.mark.asyncio @@ -152,6 +194,23 @@ async def test_analyses_endpoint_in_openapi() -> None: assert "/api/v1/internal/v1/analyses" not in paths +@pytest.mark.asyncio +async def test_intent_status_exposes_runtime_flags_without_loading_models() -> None: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/internal/v1/intent/status") + + assert resp.status_code == 200 + assert resp.json() == { + "intentModelEnabled": False, + "axEnabled": False, + "initialized": True, + "bertAvailable": False, + "axAvailable": False, + "degraded": False, + "promptVersion": "not-applicable", + } + + @pytest.mark.asyncio async def test_analyses_rejects_legacy_masked_input() -> None: body = { From d2a325ce255956b2b2ebdbc7972c63b185deedb5 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 11 Aug 2026 19:11:21 +0900 Subject: [PATCH 18/21] fix(intent): reuse PLAN decision in ANALYZE (#32) --- app/agents/intent/__init__.py | 2 - app/agents/intent/service.py | 88 +----- app/agents/pipeline.py | 182 +++-------- app/api/schemas/analyses.py | 53 ++-- docs/analyses-contract.md | 207 +++++-------- examples/analyses/request_analyze.json | 22 +- .../analyses/response_context_required.json | 21 +- examples/analyses/response_needs_info.json | 10 +- .../analyses/response_review_required.json | 15 +- tests/agents/test_analysis_pipeline.py | 293 +++++++----------- tests/agents/test_intent_hybrid.py | 8 +- tests/api/test_analyses_endpoint.py | 36 +-- 12 files changed, 291 insertions(+), 646 deletions(-) diff --git a/app/agents/intent/__init__.py b/app/agents/intent/__init__.py index e1032e3..4ecbd6b 100644 --- a/app/agents/intent/__init__.py +++ b/app/agents/intent/__init__.py @@ -4,7 +4,6 @@ FixedExpiryRenewalIntentAgent, HybridHfIntentAgent, IntentClassifier, - IntentDecision, IntentResult, build_intent_agent, ) @@ -13,7 +12,6 @@ "FixedExpiryRenewalIntentAgent", "HybridHfIntentAgent", "IntentClassifier", - "IntentDecision", "IntentResult", "build_intent_agent", ] diff --git a/app/agents/intent/service.py b/app/agents/intent/service.py index f6a5e31..fdfc803 100644 --- a/app/agents/intent/service.py +++ b/app/agents/intent/service.py @@ -78,18 +78,6 @@ def resolve_workflow_id( return candidate_workflows[0] if candidate_workflows else "" -@dataclass -# Intent 1개와 Knowledge Workflow 1개의 결정 -class IntentDecision: - - intent: str - workflow_id: str - confidence: float | None - confidence_source: str - bert_routing_score: float | None = None - evidence: str | None = None - - @dataclass # Intent 분류와 Slot 추출 결과 class IntentResult: @@ -104,7 +92,7 @@ class IntentResult: prompt_version: str = "not-applicable" confidence_source: str = "UNAVAILABLE" bert_routing_score: float | None = None - decisions: list[IntentDecision] = field(default_factory=list) + evidence: str | None = None # 교체 가능한 Intent 분류기 계약 @@ -154,15 +142,7 @@ def classify( model_name="fixed-expiry-renewal", model_version="rules", extracted_slots={}, - confidence_source="RULES", - decisions=[ - IntentDecision( - intent=intent, - workflow_id=resolve_workflow_id(intent, workflow_constraints), - confidence=1.0, - confidence_source="RULES", - ) - ], + confidence_source="MODEL", ) @@ -276,46 +256,14 @@ def classify( primary_intent, _ = _primary_intent( prediction.intents, prediction.scores ) - if prediction.selected_model == "AX": - ordered_intents = list(prediction.intents) - else: - ordered_intents = [primary_intent, *prediction.intents] - ordered_intents = list( - dict.fromkeys( - name - for name in ordered_intents - if name != "OUT_OF_SCOPE" or name == primary_intent - ) - ) - is_ax = prediction.selected_model == "AX" - decisions: list[IntentDecision] = [] - for name in ordered_intents: - bert_score = prediction.scores.get(name) - decisions.append( - IntentDecision( - intent=name, - workflow_id=resolve_workflow_id(name, workflow_constraints), - confidence=None if is_ax else float(bert_score or 0.0), - confidence_source="UNAVAILABLE" if is_ax else "BERT", - bert_routing_score=( - float(bert_score) if bert_score is not None else None - ), - evidence=(prediction.evidence or {}).get(name), - ) - ) - if not decisions: - decisions = [ - IntentDecision( - intent="OUT_OF_SCOPE", - workflow_id="", - confidence=0.0, - confidence_source="BERT", - bert_routing_score=0.0, - ) - ] - - primary = decisions[0] + # MVP는 대표 Intent 한 개만 공개한다. A.X는 원문 등장 순서의 첫 항목을 사용한다. + representative = ( + prediction.intents[0] + if is_ax and prediction.intents + else primary_intent + ) + bert_score = prediction.scores.get(representative) from app.core.config import get_settings settings = get_settings() @@ -324,22 +272,18 @@ def classify( if prediction.selected_model == "AX" else settings.intent_bert_model_dir ) - slots: dict[str, str] = {} - for name, evidence in (prediction.evidence or {}).items(): - if evidence: - slots[f"evidence:{name}"] = str(evidence) return IntentResult( - intent=primary.intent, - confidence=primary.confidence, - workflow_id=primary.workflow_id, - extracted_slots=slots, + intent=representative, + confidence=None if is_ax else float(bert_score or 0.0), + workflow_id=resolve_workflow_id(representative, workflow_constraints), + extracted_slots={}, model_provider="huggingface", model_name=model_name, model_version=prediction.selected_model, prompt_version=prediction.prompt_version, - confidence_source=primary.confidence_source, - bert_routing_score=primary.bert_routing_score, - decisions=decisions, + confidence_source="UNAVAILABLE" if is_ax else "BERT", + bert_routing_score=(float(bert_score) if bert_score is not None else None), + evidence=(prediction.evidence or {}).get(representative), ) diff --git a/app/agents/pipeline.py b/app/agents/pipeline.py index ed1db35..3e766c0 100644 --- a/app/agents/pipeline.py +++ b/app/agents/pipeline.py @@ -16,12 +16,11 @@ AnalysisResponse, AnalysisVersions, ContextRequirement, - IntentDecisionItem, WorkerContext, ) from .ambiguity import AmbiguityAgent -from .intent import IntentClassifier, IntentDecision, IntentResult, build_intent_agent +from .intent import IntentClassifier, IntentResult, build_intent_agent from .workflow import WorkflowAgent from .workflow_graph.state import HR_EXCLUDED_SLOTS @@ -117,94 +116,19 @@ def _versions(intent_result: IntentResult) -> AnalysisVersions: ) -# 구형 단일 IntentResult도 새 결정 목록 계약으로 정규화 -def _intent_decisions(intent_result: IntentResult) -> list[IntentDecision]: - if intent_result.decisions: - return list(intent_result.decisions) - return [ - IntentDecision( - intent=intent_result.intent or "UNKNOWN", - workflow_id=intent_result.workflow_id or "", - confidence=intent_result.confidence, - confidence_source=intent_result.confidence_source, - bert_routing_score=intent_result.bert_routing_score, - ) - ] - - -# 내부 결정을 Server가 ANALYZE에서 재사용할 수 있는 와이어 모델로 변환 -def _wire_intent_decisions(intent_result: IntentResult) -> list[IntentDecisionItem]: - return [ - IntentDecisionItem( - detected_intent=decision.intent, - workflow_id=decision.workflow_id, - evidence=decision.evidence, - confidence=decision.confidence, - confidence_source=decision.confidence_source, - bert_routing_score=decision.bert_routing_score, - model_provider=intent_result.model_provider, - model_name=intent_result.model_name, - model_version=intent_result.model_version, - prompt_version=intent_result.prompt_version, - ) - for decision in _intent_decisions(intent_result) - ] - - # PLAN에서 확정한 결정을 재구성해 ANALYZE 모델 재호출을 피한다. -def _planned_intent_result(request: AnalysisRequest) -> IntentResult | None: +def _planned_intent_result(request: AnalysisRequest) -> IntentResult: ai = request.analysis_input - if ai.planned_intent_decisions: - items = ai.planned_intent_decisions - decisions = [ - IntentDecision( - intent=item.detected_intent, - workflow_id=item.workflow_id, - confidence=item.confidence, - confidence_source=item.confidence_source, - bert_routing_score=item.bert_routing_score, - evidence=item.evidence, - ) - for item in items - ] - primary = decisions[0] - first = items[0] - slots = { - f"evidence:{item.detected_intent}": item.evidence - for item in items - if item.evidence - } - return IntentResult( - intent=primary.intent, - workflow_id=primary.workflow_id, - confidence=primary.confidence, - confidence_source=primary.confidence_source, - bert_routing_score=primary.bert_routing_score, - decisions=decisions, - extracted_slots=slots, - model_provider=first.model_provider, - model_name=first.model_name, - model_version=first.model_version, - prompt_version=first.prompt_version, - ) - if ai.planned_intent is not None and ai.planned_workflow_id is not None: - decision = IntentDecision( - intent=ai.planned_intent, - workflow_id=ai.planned_workflow_id, - confidence=None, - confidence_source="UNAVAILABLE", - ) - return IntentResult( - intent=decision.intent, - workflow_id=decision.workflow_id, - confidence=None, - confidence_source="UNAVAILABLE", - decisions=[decision], - model_provider="server", - model_name="planned-intent", - model_version="reused", - ) - return None + # AnalysisRequest 검증이 두 값을 필수로 보장한다. + return IntentResult( + intent=ai.planned_intent or "UNKNOWN", + workflow_id=ai.planned_workflow_id or "", + confidence=None, + confidence_source="UNAVAILABLE", + model_provider="server", + model_name="planned-intent", + model_version="reused", + ) # Intent → requiredFieldKeys / questions·candidates @@ -237,32 +161,23 @@ def run(self, request: AnalysisRequest) -> AnalysisResponse: def _run_plan(self, request: AnalysisRequest) -> AnalysisResponse: instruction = request.analysis_input.instruction intent_result = self._intent.classify(instruction) - decisions = _intent_decisions(intent_result) - primary = decisions[0] - - # 복합 Intent이면 각 Workflow의 canonical key를 원문 순서대로 합친다. - field_keys: list[str] = [] - for decision in decisions: - if decision.intent == "OUT_OF_SCOPE": - required = ["worker_id"] - else: - required = self._required_slots_for(decision.workflow_id) - if not required: - required = ["worker_id", "stay_expiry_date"] - for key in required: - if key not in field_keys: - field_keys.append(key) + workflow_id = intent_result.workflow_id or "" + if intent_result.intent == "OUT_OF_SCOPE": + field_keys = ["worker_id"] + else: + required = self._required_slots_for(workflow_id) + field_keys = list(required) if required else ["worker_id", "stay_expiry_date"] return AnalysisResponse( request_id=request.request_id, outcome="CONTEXT_REQUIRED", context_requirement=ContextRequirement( - detected_intent=primary.intent, - workflow_id=primary.workflow_id, - confidence=primary.confidence, - confidence_source=primary.confidence_source, - bert_routing_score=primary.bert_routing_score, - intent_decisions=_wire_intent_decisions(intent_result), + detected_intent=intent_result.intent or "UNKNOWN", + workflow_id=workflow_id, + evidence=intent_result.evidence, + confidence=intent_result.confidence, + confidence_source=intent_result.confidence_source, + bert_routing_score=intent_result.bert_routing_score, target_display_name=_guess_target_display_name(instruction), extracted_slots=dict(intent_result.extracted_slots), required_field_keys=field_keys, @@ -280,12 +195,7 @@ def _run_analyze(self, request: AnalysisRequest) -> AnalysisResponse: ai = request.analysis_input instruction = ai.instruction intent_result = _planned_intent_result(request) - provider_attempt_count = 0 - if intent_result is None: - # 1.0 호출자 하위호환: 계획 결정을 보내지 않으면 기존처럼 분류한다. - intent_result = self._intent.classify(instruction) - provider_attempt_count = 1 - decisions = _intent_decisions(intent_result) + workflow_id = intent_result.workflow_id if not ai.workers: return AnalysisResponse( @@ -296,7 +206,7 @@ def _run_analyze(self, request: AnalysisRequest) -> AnalysisResponse: candidates=[], validation_errors=[], versions=_versions(intent_result), - provider_attempt_count=provider_attempt_count, + provider_attempt_count=0, latency_ms=0, ) @@ -315,13 +225,10 @@ def _run_analyze(self, request: AnalysisRequest) -> AnalysisResponse: ): hr_keys.append(key) - for decision in decisions: - if not decision.workflow_id: - continue - amb = self._ambiguity.check(decision.workflow_id, slots, instruction) - for key in amb.missing_slots: - if key not in HR_EXCLUDED_SLOTS and key not in slots and key not in hr_keys: - hr_keys.append(key) + amb = self._ambiguity.check(workflow_id, slots, instruction) + for key in amb.missing_slots: + if key not in HR_EXCLUDED_SLOTS and key not in slots and key not in hr_keys: + hr_keys.append(key) if hr_keys: return AnalysisResponse( @@ -332,34 +239,27 @@ def _run_analyze(self, request: AnalysisRequest) -> AnalysisResponse: candidates=[], validation_errors=[], versions=_versions(intent_result), - provider_attempt_count=provider_attempt_count, + provider_attempt_count=0, latency_ms=0, ) - # 복합 Intent 각각에 canonical Knowledge Workflow 후보를 만든다. - candidates = [ - AnalysisCandidate( - candidate_ref=f"candidate-{uuid4().hex[:8]}", - worker_ref=worker.worker_ref, - detected_intent=decision.intent, - workflow_id=decision.workflow_id, - extracted_slots=slots, - missing_slots=[], - confidence=decision.confidence, - confidence_source=decision.confidence_source, - bert_routing_score=decision.bert_routing_score, - ) - for decision in decisions - ] + candidate = AnalysisCandidate( + candidate_ref=f"candidate-{uuid4().hex[:8]}", + worker_ref=worker.worker_ref, + workflow_id=workflow_id, + extracted_slots=slots, + missing_slots=[], + confidence=None, + ) return AnalysisResponse( request_id=request.request_id, outcome="REVIEW_REQUIRED", context_requirement=None, questions=[], - candidates=candidates, + candidates=[candidate], validation_errors=[], versions=_versions(intent_result), - provider_attempt_count=provider_attempt_count, + provider_attempt_count=0, latency_ms=0, ) diff --git a/app/api/schemas/analyses.py b/app/api/schemas/analyses.py index 2a59421..66035e4 100644 --- a/app/api/schemas/analyses.py +++ b/app/api/schemas/analyses.py @@ -2,15 +2,15 @@ from __future__ import annotations -from typing import Literal +from typing import Literal, Self -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator AnalysisPhase = Literal["PLAN", "ANALYZE"] AnalysisOutcome = Literal["CONTEXT_REQUIRED", "NEEDS_INFO", "REVIEW_REQUIRED"] -ConfidenceSource = Literal["BERT", "RULES", "UNAVAILABLE"] +ConfidenceSource = Literal["MODEL", "BERT", "UNAVAILABLE"] -DEFAULT_CONTRACT_VERSION = "1.1.0" +DEFAULT_CONTRACT_VERSION = "1.0.0" DEFAULT_KNOWLEDGE_VERSION = "0.2.0" @@ -30,37 +30,14 @@ class WorkerContext(BaseModel): model_config = {"populate_by_name": True} -class IntentDecisionItem(BaseModel): - - detected_intent: str = Field(..., alias="detectedIntent") - workflow_id: str = Field(..., alias="workflowId") - evidence: str | None = None - confidence: float | None = Field(None, ge=0.0, le=1.0) - confidence_source: ConfidenceSource = Field(..., alias="confidenceSource") - bert_routing_score: float | None = Field( - None, alias="bertRoutingScore", ge=0.0, le=1.0 - ) - model_provider: str = Field(..., alias="modelProvider") - model_name: str = Field(..., alias="modelName") - model_version: str = Field(..., alias="modelVersion") - prompt_version: str = Field(..., alias="promptVersion") - - model_config = {"populate_by_name": True} - - # HR 지시 + PLAN/ANALYZE 문맥 (HTTP 최소 페이로드) class AnalysisInput(BaseModel): instruction: str requested_field_keys: list[str] = Field(default_factory=list, alias="requestedFieldKeys") workers: list[WorkerContext] = Field(default_factory=list) - # 단일 Intent 호출자는 PLAN의 대표 결정을 그대로 되돌려 줄 수 있다. planned_intent: str | None = Field(None, alias="plannedIntent") planned_workflow_id: str | None = Field(None, alias="plannedWorkflowId") - # 복합 Intent 호출자는 PLAN의 전체 결정을 보존한다. 배열이 단일 필드보다 우선한다. - planned_intent_decisions: list[IntentDecisionItem] = Field( - default_factory=list, alias="plannedIntentDecisions" - ) model_config = {"populate_by_name": True} @@ -74,6 +51,19 @@ class AnalysisRequest(BaseModel): model_config = {"populate_by_name": True} + @model_validator(mode="after") + def validate_planned_decision_for_phase(self) -> Self: + ai = self.analysis_input + has_intent = ai.planned_intent is not None + has_workflow = ai.planned_workflow_id is not None + if self.phase == "PLAN" and (has_intent or has_workflow): + raise ValueError("PLAN must not include a planned Intent decision") + if self.phase == "ANALYZE" and not (has_intent and has_workflow): + raise ValueError( + "ANALYZE requires plannedIntent and plannedWorkflowId from PLAN" + ) + return self + # 기계 판독용 검증 오류 class ValidationErrorItem(BaseModel): @@ -89,14 +79,12 @@ class ContextRequirement(BaseModel): detected_intent: str = Field(..., alias="detectedIntent") workflow_id: str = Field(..., alias="workflowId") + evidence: str | None = None confidence: float | None = Field(None, ge=0.0, le=1.0) confidence_source: ConfidenceSource = Field(..., alias="confidenceSource") bert_routing_score: float | None = Field( None, alias="bertRoutingScore", ge=0.0, le=1.0 ) - intent_decisions: list[IntentDecisionItem] = Field( - default_factory=list, alias="intentDecisions" - ) target_display_name: str = Field(..., alias="targetDisplayName") extracted_slots: dict[str, str] = Field(default_factory=dict, alias="extractedSlots") required_field_keys: list[str] = Field(..., alias="requiredFieldKeys") @@ -118,15 +106,10 @@ class AnalysisCandidate(BaseModel): candidate_ref: str = Field(..., alias="candidateRef") worker_ref: str = Field(..., alias="workerRef", description="서버 worker_id") - detected_intent: str = Field(..., alias="detectedIntent") workflow_id: str = Field(..., alias="workflowId") extracted_slots: dict[str, str] = Field(default_factory=dict, alias="extractedSlots") missing_slots: list[str] = Field(default_factory=list, alias="missingSlots") confidence: float | None = Field(None, ge=0.0, le=1.0) - confidence_source: ConfidenceSource = Field(..., alias="confidenceSource") - bert_routing_score: float | None = Field( - None, alias="bertRoutingScore", ge=0.0, le=1.0 - ) model_config = {"populate_by_name": True} diff --git a/docs/analyses-contract.md b/docs/analyses-contract.md index 62a1a2c..877baec 100644 --- a/docs/analyses-contract.md +++ b/docs/analyses-contract.md @@ -1,28 +1,19 @@ # Analyses Runtime 계약 (AI 소유) -Server `docs/ai-runtime-contract.md` + `AiRuntimeHttpRequest` (fowoco/server main)과 맞춘다. -계약 버전은 **1.1.0**이다. `attemptId` / deadline / `workflowConstraints`는 Server 내부에만 -두며, PLAN에서 확정한 Intent 결정은 ANALYZE 요청에 되돌려 보내 재분류를 막는다. +Server PR #138의 `AiRuntimeHttpRequest`와 맞춘 계약이다. 계약 버전은 **1.0.0**이며, +MVP에서는 발화 하나당 대표 Intent와 canonical Workflow 한 쌍만 처리한다. -## Endpoint +## Endpoint와 흐름 ```text POST /internal/v1/analyses -``` - -## 흐름 -```text -PLAN → Intent/A.X 1회 → CONTEXT_REQUIRED (intentDecisions + requiredFieldKeys) - → Server DB 조회 -ANALYZE → PLAN 결정 재사용(모델 0회) → NEEDS_INFO (questions) | REVIEW_REQUIRED (candidates) +PLAN → Intent 모델 1회 → CONTEXT_REQUIRED + → Server가 대표 Intent/Workflow 저장 + DB 조회 +ANALYZE → PLAN 결정 재사용(모델 0회) → NEEDS_INFO | REVIEW_REQUIRED ``` -`CONTEXT_REQUIRED` / `NEEDS_INFO` / `REVIEW_REQUIRED` 는 모두 **성공 outcome** 이다 (`FAILED` 아님). - ---- - -## 1) PLAN 요청 (Server → AI) +## PLAN 요청 ```json { @@ -34,16 +25,9 @@ ANALYZE → PLAN 결정 재사용(모델 0회) → NEEDS_INFO (questions) | REVI } ``` -| 필드 | 규칙 | -|---|---| -| `requestId` | 필수. 응답에 그대로 에코 | -| `phase` | `"PLAN"` | -| `analysisInput.instruction` | HR 발화 **원문만** (Intent 태그·코드 미부착, Issue #6) | -| workers / requestedFieldKeys | **보내지 않음** (PLAN) | -| `intentHint` | **없음** (폐기) | -| attemptId / contractVersion / deadlineMs 등 | HTTP에 **없음** (Server 내부) | +PLAN에는 `plannedIntent`, `plannedWorkflowId`, Worker context를 보내지 않는다. -## 2) CONTEXT_REQUIRED 응답 (AI → Server) +## CONTEXT_REQUIRED 응답 ```json { @@ -52,46 +36,49 @@ ANALYZE → PLAN 결정 재사용(모델 0회) → NEEDS_INFO (questions) | REVI "contextRequirement": { "detectedIntent": "EXPIRY_RENEWAL", "workflowId": "WF-STY-001", + "evidence": "체류연장 준비해줘", "confidence": null, "confidenceSource": "UNAVAILABLE", "bertRoutingScore": 0.3088, - "intentDecisions": [ - { - "detectedIntent": "EXPIRY_RENEWAL", - "workflowId": "WF-STY-001", - "evidence": "체류연장 준비해줘", - "confidence": null, - "confidenceSource": "UNAVAILABLE", - "bertRoutingScore": 0.3088, - "modelProvider": "huggingface", - "modelName": "skt/A.X-4.0-Light", - "modelVersion": "AX", - "promptVersion": "knowledge-25e778ad" - } - ], "targetDisplayName": "응웬반안", "extractedSlots": {}, - "requiredFieldKeys": ["worker_id", "stay_expiry_date"] + "requiredFieldKeys": [ + "worker_id", + "stay_expiry_date", + "passport_status", + "arc_status" + ] }, "questions": [], "candidates": [], "validationErrors": [], - "versions": { "...": "..." }, + "versions": { + "agentVersion": "0.1.0", + "modelProvider": "huggingface", + "modelName": "skt/A.X-4.0-Light", + "modelVersion": "AX", + "promptVersion": "knowledge-25e778ad", + "contextPackVersion": "0.2.0", + "workflowCatalogVersion": "0.2.0", + "contractVersion": "1.0.0" + }, "providerAttemptCount": 1, "latencyMs": 120 } ``` -| 규칙 | 내용 | -|---|---| -| `requiredFieldKeys` | 비어 있으면 Server 거부. Knowledge canonical key만 (`worker_id` 포함) | -| `questions` / `candidates` | 비움 | -| `confidence` | BERT/RULES는 0.0~1.0. A.X는 score가 없으므로 `null` | -| `confidenceSource` | `BERT`, `RULES`, `UNAVAILABLE` 중 하나 | -| `bertRoutingScore` | A.X 선택 전 라우팅 참고값. A.X confidence로 해석하면 안 됨 | -| `intentDecisions` | 복합 Intent를 원문 순서대로 보존. 각 항목은 canonical `workflowId` 포함 | +규칙: + +- `workflowId`는 `WF-STY-001` 같은 Knowledge canonical ID다. +- A.X는 확률을 제공하지 않으므로 `confidence=null`, `confidenceSource=UNAVAILABLE`이다. +- BERT가 최종 분류기이면 `confidenceSource=BERT`이고 confidence를 반환한다. +- 고정 규칙 fallback은 `confidenceSource=MODEL`을 사용한다. +- `bertRoutingScore`는 A.X 선택 전 참고값이며 A.X confidence가 아니다. +- `evidence`는 A.X의 원문 substring이다. BERT와 OUT_OF_SCOPE에서는 null일 수 있다. +- evidence는 Slot이 아니므로 `extractedSlots`에 `evidence:*` key를 만들지 않는다. +- A.X가 여러 Intent를 반환해도 MVP 응답은 원문 등장 순서의 첫 Intent만 사용한다. -## 3) ANALYZE 요청 (Server → AI) +## ANALYZE 요청 ```json { @@ -101,21 +88,12 @@ ANALYZE → PLAN 결정 재사용(모델 0회) → NEEDS_INFO (questions) | REVI "instruction": "응웬반안 체류연장 준비해줘", "plannedIntent": "EXPIRY_RENEWAL", "plannedWorkflowId": "WF-STY-001", - "plannedIntentDecisions": [ - { - "detectedIntent": "EXPIRY_RENEWAL", - "workflowId": "WF-STY-001", - "evidence": "체류연장 준비해줘", - "confidence": null, - "confidenceSource": "UNAVAILABLE", - "bertRoutingScore": 0.3088, - "modelProvider": "huggingface", - "modelName": "skt/A.X-4.0-Light", - "modelVersion": "AX", - "promptVersion": "knowledge-25e778ad" - } + "requestedFieldKeys": [ + "worker_id", + "stay_expiry_date", + "passport_status", + "arc_status" ], - "requestedFieldKeys": ["worker_id", "stay_expiry_date"], "workers": [ { "workerRef": "30000000-0000-0000-0000-000000000001", @@ -129,100 +107,67 @@ ANALYZE → PLAN 결정 재사용(모델 0회) → NEEDS_INFO (questions) | REVI } ``` -| 필드 | 규칙 | -|---|---| -| `requestedFieldKeys` | PLAN에서 Agent가 요청한 **전체** key (DB 미조회여도 목록 유지) | -| `workers[].requestedFields` | Server가 **실제로 찾은 값만** | -| `plannedIntentDecisions` | PLAN 응답의 `intentDecisions`를 변경 없이 전달. 복합 Intent 권장 계약 | -| `plannedIntent` / `plannedWorkflowId` | 단일 Intent 호출자의 최소 재사용 계약 | -| DB 미조회 키 | `requestedFieldKeys − requestedFields.keys` → HR 질문 후보 | -| MVP | Worker **1명** | -| HTTP에 안 실림 | `extractedSlots`, `workflowConstraints`, attemptId, versions, deadline | +규칙: -`plannedIntentDecisions`가 있으면 배열이 단일 필드보다 우선한다. 두 계약이 모두 없을 때만 -1.0 하위호환을 위해 Intent 모델을 다시 호출하며, 이 경로는 Server 전환 후 제거할 수 있다. +- `plannedIntent`, `plannedWorkflowId`는 모두 필수다. 하나라도 없으면 422로 거부한다. +- AI는 이 값을 신뢰하여 Intent 모델을 다시 호출하지 않고 Slot/Context만 검사한다. +- `providerAttemptCount=0`은 ANALYZE에서 모델 호출이 없었음을 뜻한다. +- `requestedFieldKeys`는 PLAN에서 요청한 전체 key다. +- `workers[].requestedFields`에는 Server DB에서 실제로 찾은 값만 담는다. +- MVP는 Worker 한 명과 대표 Intent/Workflow 한 쌍만 처리한다. -## 4) ANALYZE 응답 +## ANALYZE 응답 ### NEEDS_INFO -- `contextRequirement`: null -- `candidates`: [] -- `questions`: **1개 이상** `{ "slotKey", "prompt" }` +- `contextRequirement`: null +- `candidates`: [] +- `questions`: 한 개 이상 ### REVIEW_REQUIRED -- `contextRequirement`: null -- `questions`: [] -- `candidates`: **1개 이상** (기존 AiCandidate 필드) -- `detectedIntent`는 `EXPIRY_RENEWAL` 같은 업무 종류이며, 후보의 `workflowId`는 `WF-STY-001` 같은 구체적인 Workflow Catalog ID다. -- `workflowId`에 Intent 코드를 다시 넣지 않는다. - ```json { "candidateRef": "candidate-1", "workerRef": "30000000-0000-0000-0000-000000000001", - "detectedIntent": "EXPIRY_RENEWAL", "workflowId": "WF-STY-001", "extractedSlots": { "worker_id": "30000000-0000-0000-0000-000000000001", - "stay_expiry_date": "2026-12-31", - "full_name": "NGUYEN VAN AN" + "stay_expiry_date": "2026-12-31" }, "missingSlots": [], - "confidence": null, - "confidenceSource": "UNAVAILABLE", - "bertRoutingScore": 0.3088 + "confidence": null } ``` -`missingSlots`는 REVIEW 직전 필수 슬롯이 모두 채워졌을 때 **빈 배열**이다. -남은 HR 입력은 `NEEDS_INFO.questions`로 보낸다. +Candidate의 `workflowId`는 `plannedWorkflowId`와 반드시 같아야 한다. ANALYZE는 모델을 +재호출하지 않고 Server가 confidence를 다시 보내지 않으므로 AI는 새 점수를 만들지 않는다. -공통 응답 필드: `validationErrors`, `versions`, `providerAttemptCount`, `latencyMs`. +## Server #138 확인·대기 항목 -### versions (응답 필수) +PR #138은 A.X의 nullable confidence, nullable evidence, ANALYZE의 +`providerAttemptCount=0`을 이미 허용한다. 따라서 A.X 대표 Intent 경로는 이 계약과 맞는다. -Server가 내부 요청의 `contractVersion` / `requiredKnowledgeVersion` 과 -응답 `versions.contractVersion` / `versions.workflowCatalogVersion` 을 대조한다. -HTTP 요청에 version이 없어도 AI는 기본값 **`1.1.0` / `0.2.0`** 을 맞춰야 한다. +남은 경계 사례는 BERT 경로다. Server는 ANALYZE Candidate confidence가 PLAN confidence와 +같기를 요구하지만, HTTP 요청에는 `plannedIntent`, `plannedWorkflowId`만 보내므로 AI가 PLAN의 +BERT 점수를 알 수 없다. AI는 재분류하거나 점수를 만들지 않고 Candidate confidence를 null로 +유지한다. Server는 BERT 경로에서 Candidate confidence 비교를 제거하거나, 별도 계약 합의 후 +PLAN confidence를 ANALYZE에 전달해야 한다. ---- - -## 우리(AI) 구현 상태 - -| 항목 | Server HTTP | AI (`schemas` / `pipeline`) | -|---|---|---| -| `phase` PLAN/ANALYZE | 필수 | **반영** | -| `CONTEXT_REQUIRED` | 있음 | **반영** | -| `questions` | NEEDS_INFO | **반영** | -| ANALYZE `requestedFieldKeys` | 있음 | **반영** | -| PLAN 결정 재사용 | plannedIntent(s) | **반영** (ANALYZE providerAttemptCount=0) | -| 복합 Intent | intentDecisions[] | **반영** (Intent별 candidate) | -| A.X confidence | score 없음 | **null + BERT routing score 분리** | -| workers 최소 필드 | workerRef + requestedFields | **반영** (추가 필드는 선택) | -| attemptId 등 | HTTP 미전송 | **요청에서 제거** | -| 슬롯 기준 | Knowledge | Ambiguity/Workflow catalog | -| versions | 응답 필수 | `1.1.0` / `0.2.0` 고정 | - -## Intent 분류기 +## Intent 운영 설정 | 설정 | 동작 | |---|---| -| `FOWOCO_INTENT_MODEL_ENABLED=false` (기본) | `EXPIRY_RENEWAL` 고정 stub | -| `FOWOCO_INTENT_MODEL_ENABLED=true` | HF BERT(+선택 A.X) 하이브리드 | +| `FOWOCO_INTENT_MODEL_ENABLED=false` | `EXPIRY_RENEWAL` 고정 규칙 | +| `FOWOCO_INTENT_MODEL_ENABLED=true` | HF BERT와 선택적 A.X 하이브리드 | -필요 시 BERT만: `pip install -e ".[intent]"` (Windows CPU 권장). -A.X까지: `pip install -e ".[intent-ax]"` (Linux/CUDA; Windows에선 `bitsandbytes` 실패 흔함). -`.env`에 `FOWOCO_HF_TOKEN` 또는 `HF_TOKEN`, -`FOWOCO_INTENT_BERT_MODEL_DIR=fowoco/klue-roberta-base-intent-classifier`. -로컬 CPU는 `FOWOCO_INTENT_ENABLE_AX=false` 권장. 운영은 실제 추론 장치에 맞춰 -`FOWOCO_INTENT_DEVICE`를 설정하고 private 모델 토큰은 Kubernetes Secret으로만 주입한다. -BERT/Base/Adapter의 `*_REVISION`은 배포 전에 immutable Hugging Face commit SHA로 고정한다. +A.X 사용 이미지는 `intent-ax` extra를 설치한다. 운영 장치에 맞춰 +`FOWOCO_INTENT_DEVICE`를 설정하고 private HF Token은 Kubernetes Secret으로 주입한다. +BERT/Base/Adapter의 `*_REVISION`은 immutable Hugging Face commit SHA로 고정한다. -`GET /internal/v1/intent/status`에서 설정 활성화, lazy-load 완료 여부, BERT/A.X 가용성과 -`promptVersion`을 확인한다. 상태 조회는 모델을 강제로 로드하지 않으므로 배포 smoke PLAN 후 -`axAvailable=true`, `promptVersion=knowledge-25e778ad`를 확인한다. +`GET /internal/v1/intent/status`에서 모델 설정, lazy-load 상태, BERT/A.X 가용성과 +`promptVersion`을 확인한다. 배포 smoke PLAN 후 `axAvailable=true`, +`promptVersion=knowledge-25e778ad`를 확인한다. ## Fixtures @@ -233,7 +178,3 @@ BERT/Base/Adapter의 `*_REVISION`은 배포 전에 immutable Hugging Face commit | `examples/analyses/request_analyze.json` | ANALYZE 요청 | | `examples/analyses/response_needs_info.json` | NEEDS_INFO | | `examples/analyses/response_review_required.json` | REVIEW_REQUIRED | - -## 핸드셰이크 (#8) - -[ai-runtime-handshake.md](ai-runtime-handshake.md) — Bearer, `X-Request-Id` = requestId. diff --git a/examples/analyses/request_analyze.json b/examples/analyses/request_analyze.json index 4364504..403da04 100644 --- a/examples/analyses/request_analyze.json +++ b/examples/analyses/request_analyze.json @@ -5,30 +5,20 @@ "instruction": "응웬반안 체류연장 준비해줘", "plannedIntent": "EXPIRY_RENEWAL", "plannedWorkflowId": "WF-STY-001", - "plannedIntentDecisions": [ - { - "detectedIntent": "EXPIRY_RENEWAL", - "workflowId": "WF-STY-001", - "evidence": "체류연장 준비해줘", - "confidence": null, - "confidenceSource": "UNAVAILABLE", - "bertRoutingScore": 0.3088, - "modelProvider": "huggingface", - "modelName": "skt/A.X-4.0-Light", - "modelVersion": "AX", - "promptVersion": "knowledge-25e778ad" - } - ], "requestedFieldKeys": [ "worker_id", - "stay_expiry_date" + "stay_expiry_date", + "passport_status", + "arc_status" ], "workers": [ { "workerRef": "30000000-0000-0000-0000-000000000001", "requestedFields": { "worker_id": "30000000-0000-0000-0000-000000000001", - "stay_expiry_date": "2026-12-31" + "stay_expiry_date": "2026-12-31", + "passport_status": "VALID", + "arc_status": "VALID" } } ] diff --git a/examples/analyses/response_context_required.json b/examples/analyses/response_context_required.json index a1279f2..d8f7f66 100644 --- a/examples/analyses/response_context_required.json +++ b/examples/analyses/response_context_required.json @@ -4,28 +4,17 @@ "contextRequirement": { "detectedIntent": "EXPIRY_RENEWAL", "workflowId": "WF-STY-001", + "evidence": "체류연장 준비해줘", "confidence": null, "confidenceSource": "UNAVAILABLE", "bertRoutingScore": 0.3088, - "intentDecisions": [ - { - "detectedIntent": "EXPIRY_RENEWAL", - "workflowId": "WF-STY-001", - "evidence": "체류연장 준비해줘", - "confidence": null, - "confidenceSource": "UNAVAILABLE", - "bertRoutingScore": 0.3088, - "modelProvider": "huggingface", - "modelName": "skt/A.X-4.0-Light", - "modelVersion": "AX", - "promptVersion": "knowledge-25e778ad" - } - ], "targetDisplayName": "응웬반안", "extractedSlots": {}, "requiredFieldKeys": [ "worker_id", - "stay_expiry_date" + "stay_expiry_date", + "passport_status", + "arc_status" ] }, "questions": [], @@ -39,7 +28,7 @@ "promptVersion": "knowledge-25e778ad", "contextPackVersion": "0.2.0", "workflowCatalogVersion": "0.2.0", - "contractVersion": "1.1.0" + "contractVersion": "1.0.0" }, "providerAttemptCount": 1, "latencyMs": 120 diff --git a/examples/analyses/response_needs_info.json b/examples/analyses/response_needs_info.json index 11e46a7..f858166 100644 --- a/examples/analyses/response_needs_info.json +++ b/examples/analyses/response_needs_info.json @@ -12,13 +12,13 @@ "validationErrors": [], "versions": { "agentVersion": "0.1.0", - "modelProvider": "huggingface", - "modelName": "skt/A.X-4.0-Light", - "modelVersion": "AX", - "promptVersion": "knowledge-25e778ad", + "modelProvider": "server", + "modelName": "planned-intent", + "modelVersion": "reused", + "promptVersion": "not-applicable", "contextPackVersion": "0.2.0", "workflowCatalogVersion": "0.2.0", - "contractVersion": "1.1.0" + "contractVersion": "1.0.0" }, "providerAttemptCount": 0, "latencyMs": 180 diff --git a/examples/analyses/response_review_required.json b/examples/analyses/response_review_required.json index 9810d18..0ecea2a 100644 --- a/examples/analyses/response_review_required.json +++ b/examples/analyses/response_review_required.json @@ -7,7 +7,6 @@ { "candidateRef": "candidate-1", "workerRef": "30000000-0000-0000-0000-000000000001", - "detectedIntent": "EXPIRY_RENEWAL", "workflowId": "WF-STY-001", "extractedSlots": { "worker_id": "30000000-0000-0000-0000-000000000001", @@ -15,21 +14,19 @@ "full_name": "NGUYEN VAN AN" }, "missingSlots": [], - "confidence": null, - "confidenceSource": "UNAVAILABLE", - "bertRoutingScore": 0.3088 + "confidence": null } ], "validationErrors": [], "versions": { "agentVersion": "0.1.0", - "modelProvider": "huggingface", - "modelName": "skt/A.X-4.0-Light", - "modelVersion": "AX", - "promptVersion": "knowledge-25e778ad", + "modelProvider": "server", + "modelName": "planned-intent", + "modelVersion": "reused", + "promptVersion": "not-applicable", "contextPackVersion": "0.2.0", "workflowCatalogVersion": "0.2.0", - "contractVersion": "1.1.0" + "contractVersion": "1.0.0" }, "providerAttemptCount": 0, "latencyMs": 245 diff --git a/tests/agents/test_analysis_pipeline.py b/tests/agents/test_analysis_pipeline.py index d3df36b..30180f7 100644 --- a/tests/agents/test_analysis_pipeline.py +++ b/tests/agents/test_analysis_pipeline.py @@ -2,12 +2,14 @@ from uuid import uuid4 -from app.agents.intent.service import IntentDecision, IntentResult +import pytest +from pydantic import ValidationError + +from app.agents.intent.service import IntentResult from app.agents.pipeline import AnalysisPipeline from app.api.schemas.analyses import AnalysisInput, AnalysisRequest, WorkerContext -# IntentClassifier Protocol용 고정 분류기 class _FakeIntent: def __init__( self, @@ -15,12 +17,12 @@ def __init__( intent: str, confidence: float | None = 0.9, workflow_id: str = "", - decisions: list[IntentDecision] | None = None, + evidence: str | None = None, ) -> None: self.intent = intent self.confidence = confidence self.workflow_id = workflow_id - self.decisions = decisions or [] + self.evidence = evidence self.calls = 0 def runtime_status(self) -> dict[str, object]: @@ -41,242 +43,167 @@ def classify( model_provider="test", model_name="fake-intent", model_version="1", - extracted_slots={}, prompt_version="test-prompt-v1", confidence_source="BERT", bert_routing_score=self.confidence, - decisions=self.decisions, + evidence=self.evidence, ) -# PLAN → CONTEXT_REQUIRED + requiredFieldKeys -def test_plan_returns_context_required_for_expiry() -> None: - pipe = AnalysisPipeline( - intent_agent=_FakeIntent(intent="EXPIRY_RENEWAL", workflow_id="WF-STY-001") - ) - req = AnalysisRequest( +def _analyze_request( + *, + workers: list[WorkerContext], + requested_field_keys: list[str], + instruction: str = "체류연장 준비해줘", +) -> AnalysisRequest: + return AnalysisRequest( requestId=str(uuid4()), - phase="PLAN", - analysisInput=AnalysisInput(instruction="응웬반안 체류연장 준비해줘"), + phase="ANALYZE", + analysisInput=AnalysisInput( + instruction=instruction, + plannedIntent="EXPIRY_RENEWAL", + plannedWorkflowId="WF-STY-001", + requestedFieldKeys=requested_field_keys, + workers=workers, + ), ) - res = pipe.run(req) - assert res.outcome == "CONTEXT_REQUIRED" - assert res.context_requirement is not None - assert res.context_requirement.detected_intent == "EXPIRY_RENEWAL" - assert res.context_requirement.workflow_id == "WF-STY-001" - assert res.context_requirement.confidence_source == "BERT" - assert res.context_requirement.intent_decisions[0].workflow_id == "WF-STY-001" - assert res.versions.model_provider == "test" - assert res.versions.model_name == "fake-intent" - assert res.versions.model_version == "1" - assert res.versions.prompt_version == "test-prompt-v1" - assert "worker_id" in res.context_requirement.required_field_keys - assert "passport_status" in res.context_requirement.required_field_keys - assert "arc_status" in res.context_requirement.required_field_keys -def test_analyze_does_not_ask_hr_for_document_managed_fields() -> None: +def test_plan_returns_single_context_decision_without_fake_evidence_slot() -> None: pipe = AnalysisPipeline( - intent_agent=_FakeIntent(intent="EXPIRY_RENEWAL", workflow_id="WF-STY-001") - ) - worker = WorkerContext( - workerRef="30000000-0000-0000-0000-000000000001", - requestedFields={ - "worker_id": "30000000-0000-0000-0000-000000000001", - "stay_expiry_date": "2026-12-31", - }, + intent_agent=_FakeIntent( + intent="EXPIRY_RENEWAL", + workflow_id="WF-STY-001", + evidence="체류연장 준비해줘", + ) ) - req = AnalysisRequest( - requestId=str(uuid4()), - phase="ANALYZE", - analysisInput=AnalysisInput( - instruction="체류 연장", - requestedFieldKeys=[ - "worker_id", - "passport_status", - "arc_status", - "arc_expiry_date", - ], - workers=[worker], - ), + res = pipe.run( + AnalysisRequest( + requestId=str(uuid4()), + phase="PLAN", + analysisInput=AnalysisInput(instruction="응웬반안 체류연장 준비해줘"), + ) ) - res = pipe.run(req) - - assert res.outcome == "REVIEW_REQUIRED" - assert res.questions == [] + assert res.outcome == "CONTEXT_REQUIRED" + assert res.context_requirement is not None + ctx = res.context_requirement + assert ctx.detected_intent == "EXPIRY_RENEWAL" + assert ctx.workflow_id == "WF-STY-001" + assert ctx.evidence == "체류연장 준비해줘" + assert ctx.confidence_source == "BERT" + assert ctx.extracted_slots == {} + assert res.versions.prompt_version == "test-prompt-v1" + assert "worker_id" in ctx.required_field_keys + assert "passport_status" in ctx.required_field_keys + assert "arc_status" in ctx.required_field_keys -# OUT_OF_SCOPE PLAN은 worker_id만 요청 def test_plan_out_of_scope_requests_worker_id_only() -> None: pipe = AnalysisPipeline(intent_agent=_FakeIntent(intent="OUT_OF_SCOPE", confidence=0.7)) - req = AnalysisRequest( - requestId=str(uuid4()), - phase="PLAN", - analysisInput=AnalysisInput(instruction="오늘 날씨 어때"), + res = pipe.run( + AnalysisRequest( + requestId=str(uuid4()), + phase="PLAN", + analysisInput=AnalysisInput(instruction="오늘 날씨 어때"), + ) ) - res = pipe.run(req) - assert res.outcome == "CONTEXT_REQUIRED" + assert res.context_requirement is not None assert res.context_requirement.required_field_keys == ["worker_id"] -# ANALYZE workers 없으면 NEEDS_INFO -def test_analyze_without_workers_needs_info() -> None: - pipe = AnalysisPipeline( - intent_agent=_FakeIntent(intent="EXPIRY_RENEWAL", workflow_id="WF-STY-001") - ) - req = AnalysisRequest( - requestId=str(uuid4()), - phase="ANALYZE", - analysisInput=AnalysisInput( - instruction="체류연장", - requestedFieldKeys=["worker_id", "stay_expiry_date"], +def test_analyze_requires_planned_intent_and_workflow() -> None: + with pytest.raises(ValidationError, match="requires plannedIntent"): + AnalysisRequest( + requestId=str(uuid4()), + phase="ANALYZE", + analysisInput=AnalysisInput( + instruction="체류연장", + requestedFieldKeys=["worker_id"], + workers=[], + ), + ) + + +def test_analyze_without_workers_needs_info_without_model_call() -> None: + intent = _FakeIntent(intent="DOCUMENT_REQUEST", workflow_id="WF-DOC-001") + pipe = AnalysisPipeline(intent_agent=intent) + + res = pipe.run( + _analyze_request( workers=[], - ), + requested_field_keys=["worker_id", "stay_expiry_date"], + ) ) - res = pipe.run(req) + assert res.outcome == "NEEDS_INFO" assert any(q.slot_key == "worker_id" for q in res.questions) + assert res.provider_attempt_count == 0 + assert intent.calls == 0 -# ANALYZE 슬롯 충족 시 REVIEW_REQUIRED + missingSlots 빈 목록 -def test_analyze_filled_slots_review_required() -> None: - pipe = AnalysisPipeline( - intent_agent=_FakeIntent( - intent="EXPIRY_RENEWAL", confidence=0.91, workflow_id="WF-STY-001" - ) - ) +def test_analyze_does_not_ask_hr_for_document_managed_fields() -> None: + intent = _FakeIntent(intent="DOCUMENT_REQUEST", workflow_id="WF-DOC-001") + pipe = AnalysisPipeline(intent_agent=intent) worker = WorkerContext( - workerRef="30000000-0000-0000-0000-000000000001", + workerRef="worker-1", requestedFields={ - "worker_id": "30000000-0000-0000-0000-000000000001", + "worker_id": "worker-1", "stay_expiry_date": "2026-12-31", - "contract_end_date": "2026-12-31", - "legal_name": "NGUYEN VAN AN", - "passport_number": "M12345678", - "alien_registration_number": "123456-7890123", - "date_of_birth": "1990-01-01", - "nationality": "VN", - "full_name": "NGUYEN VAN AN", }, ) - req = AnalysisRequest( - requestId=str(uuid4()), - phase="ANALYZE", - analysisInput=AnalysisInput( - instruction="응웬반안 체류연장", - requestedFieldKeys=list(worker.requested_fields.keys()), - workers=[worker], - ), - ) - res = pipe.run(req) - assert res.outcome == "REVIEW_REQUIRED" - assert len(res.candidates) == 1 - assert res.candidates[0].missing_slots == [] - assert res.candidates[0].workflow_id == "WF-STY-001" - - -def test_analyze_reuses_plan_decisions_without_classifying_again() -> None: - intent = _FakeIntent( - intent="EXPIRY_RENEWAL", confidence=0.91, workflow_id="WF-STY-001" - ) - pipe = AnalysisPipeline(intent_agent=intent) - plan = pipe.run( - AnalysisRequest( - requestId=str(uuid4()), - phase="PLAN", - analysisInput=AnalysisInput(instruction="체류연장 준비해줘"), - ) - ) - assert plan.context_requirement is not None - analyze = pipe.run( - AnalysisRequest( - requestId=str(uuid4()), - phase="ANALYZE", - analysisInput=AnalysisInput( - instruction="체류연장 준비해줘", - requestedFieldKeys=plan.context_requirement.required_field_keys, - plannedIntentDecisions=plan.context_requirement.intent_decisions, - workers=[ - WorkerContext( - workerRef="worker-1", - requestedFields={ - "worker_id": "worker-1", - "stay_expiry_date": "2026-12-31", - }, - ) - ], - ), + res = pipe.run( + _analyze_request( + workers=[worker], + requested_field_keys=[ + "worker_id", + "passport_status", + "arc_status", + "arc_expiry_date", + ], ) ) - assert intent.calls == 1 - assert analyze.provider_attempt_count == 0 - assert analyze.outcome == "REVIEW_REQUIRED" - assert analyze.candidates[0].detected_intent == "EXPIRY_RENEWAL" + assert res.outcome == "REVIEW_REQUIRED" + assert res.questions == [] + assert intent.calls == 0 -def test_multi_intent_unions_plan_fields_and_builds_one_candidate_per_intent() -> None: - decisions = [ - IntentDecision( - intent="EXPIRY_RENEWAL", - workflow_id="WF-STY-001", - confidence=None, - confidence_source="UNAVAILABLE", - bert_routing_score=0.31, - evidence="체류연장 준비하고", - ), - IntentDecision( - intent="PAYROLL_EXPLANATION", - workflow_id="WF-PAY-001", - confidence=None, - confidence_source="UNAVAILABLE", - bert_routing_score=0.22, - evidence="급여도 확인해줘", - ), - ] +def test_analyze_reuses_plan_decision_without_classifying_again() -> None: intent = _FakeIntent( intent="EXPIRY_RENEWAL", - confidence=None, + confidence=0.91, workflow_id="WF-STY-001", - decisions=decisions, + evidence="체류연장 준비해줘", ) pipe = AnalysisPipeline(intent_agent=intent) plan = pipe.run( AnalysisRequest( requestId=str(uuid4()), phase="PLAN", - analysisInput=AnalysisInput( - instruction="체류연장 준비하고 급여도 확인해줘" - ), + analysisInput=AnalysisInput(instruction="체류연장 준비해줘"), ) ) assert plan.context_requirement is not None - assert plan.context_requirement.required_field_keys == [ - "worker_id", - "stay_expiry_date", - "passport_status", - "arc_status", - "pay_period", - ] + ctx = plan.context_requirement analyze = pipe.run( AnalysisRequest( requestId=str(uuid4()), phase="ANALYZE", analysisInput=AnalysisInput( - instruction="체류연장 준비하고 급여도 확인해줘", - requestedFieldKeys=plan.context_requirement.required_field_keys, - plannedIntentDecisions=plan.context_requirement.intent_decisions, + instruction="체류연장 준비해줘", + plannedIntent=ctx.detected_intent, + plannedWorkflowId=ctx.workflow_id, + requestedFieldKeys=ctx.required_field_keys, workers=[ WorkerContext( workerRef="worker-1", requestedFields={ "worker_id": "worker-1", "stay_expiry_date": "2026-12-31", - "pay_period": "2026-08", }, ) ], @@ -286,12 +213,8 @@ def test_multi_intent_unions_plan_fields_and_builds_one_candidate_per_intent() - assert intent.calls == 1 assert analyze.provider_attempt_count == 0 - assert [candidate.detected_intent for candidate in analyze.candidates] == [ - "EXPIRY_RENEWAL", - "PAYROLL_EXPLANATION", - ] - assert [candidate.workflow_id for candidate in analyze.candidates] == [ - "WF-STY-001", - "WF-PAY-001", - ] - assert all(candidate.confidence is None for candidate in analyze.candidates) + assert analyze.outcome == "REVIEW_REQUIRED" + assert len(analyze.candidates) == 1 + assert analyze.candidates[0].workflow_id == "WF-STY-001" + assert analyze.candidates[0].confidence is None + assert not any(key.startswith("evidence:") for key in analyze.candidates[0].extracted_slots) diff --git a/tests/agents/test_intent_hybrid.py b/tests/agents/test_intent_hybrid.py index 52e8b2c..5d47425 100644 --- a/tests/agents/test_intent_hybrid.py +++ b/tests/agents/test_intent_hybrid.py @@ -36,6 +36,7 @@ def test_build_intent_agent_defaults_to_fixed() -> None: result = agent.classify("아무 말") assert result.intent == "EXPIRY_RENEWAL" assert result.workflow_id in {"WF-STY-001", "WF-CON-001", ""} + assert result.confidence_source == "MODEL" # 주입된 파이프라인으로 Hybrid 에이전트 분류 @@ -83,11 +84,8 @@ def predict(self, instruction: str) -> HybridIntentPrediction: assert result.confidence is None assert result.confidence_source == "UNAVAILABLE" assert result.bert_routing_score == 0.2 - assert [decision.intent for decision in result.decisions] == [ - "DOCUMENT_REQUEST", - "EXPIRY_RENEWAL", - ] - assert all(decision.confidence is None for decision in result.decisions) + assert result.evidence == "서류를 요청해" + assert result.extracted_slots == {} assert result.prompt_version == AX_INTENT_PROMPT_VERSION diff --git a/tests/api/test_analyses_endpoint.py b/tests/api/test_analyses_endpoint.py index 20668cb..1700f1b 100644 --- a/tests/api/test_analyses_endpoint.py +++ b/tests/api/test_analyses_endpoint.py @@ -60,13 +60,14 @@ async def test_plan_returns_context_required() -> None: ctx = data["contextRequirement"] assert ctx["detectedIntent"] == "EXPIRY_RENEWAL" assert ctx["workflowId"] == "WF-STY-001" - assert ctx["confidenceSource"] == "RULES" + assert ctx["evidence"] is None + assert ctx["confidenceSource"] == "MODEL" assert ctx["bertRoutingScore"] is None - assert ctx["intentDecisions"][0]["workflowId"] == "WF-STY-001" + assert "intentDecisions" not in ctx assert ctx["targetDisplayName"] == "응웬반안" assert "stay_expiry_date" in ctx["requiredFieldKeys"] assert "worker_id" in ctx["requiredFieldKeys"] - assert data["versions"]["contractVersion"] == "1.1.0" + assert data["versions"]["contractVersion"] == "1.0.0" assert data["versions"]["workflowCatalogVersion"] == "0.2.0" assert data["versions"]["modelProvider"] != "stub" assert data["versions"]["modelName"] != "stub" @@ -88,10 +89,11 @@ async def test_analyze_returns_review_required_when_slots_filled() -> None: assert len(data["candidates"]) == 1 candidate = data["candidates"][0] assert candidate["workerRef"] == "30000000-0000-0000-0000-000000000001" - assert candidate["detectedIntent"] == "EXPIRY_RENEWAL" assert candidate["workflowId"] == "WF-STY-001" assert candidate["confidence"] is None - assert candidate["confidenceSource"] == "UNAVAILABLE" + assert "detectedIntent" not in candidate + assert "confidenceSource" not in candidate + assert "bertRoutingScore" not in candidate assert candidate["extractedSlots"]["stay_expiry_date"] == "2026-12-31" assert candidate["extractedSlots"]["worker_id"] == ( "30000000-0000-0000-0000-000000000001" @@ -100,34 +102,14 @@ async def test_analyze_returns_review_required_when_slots_filled() -> None: @pytest.mark.asyncio -async def test_analyze_reuses_full_ax_plan_contract() -> None: +async def test_analyze_rejects_request_without_planned_decision() -> None: body = _analyze_body() body["analysisInput"].pop("plannedIntent") body["analysisInput"].pop("plannedWorkflowId") - body["analysisInput"]["plannedIntentDecisions"] = [ - { - "detectedIntent": "EXPIRY_RENEWAL", - "workflowId": "WF-STY-001", - "evidence": "체류연장 준비해줘", - "confidence": None, - "confidenceSource": "UNAVAILABLE", - "bertRoutingScore": 0.3088, - "modelProvider": "huggingface", - "modelName": "skt/A.X-4.0-Light", - "modelVersion": "AX", - "promptVersion": "knowledge-25e778ad", - } - ] async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post(ANALYSES_PATH, json=body) - assert resp.status_code == 200 - data = resp.json() - assert data["providerAttemptCount"] == 0 - assert data["versions"]["modelVersion"] == "AX" - assert data["versions"]["promptVersion"] == "knowledge-25e778ad" - assert data["candidates"][0]["confidence"] is None - assert data["candidates"][0]["bertRoutingScore"] == 0.3088 + assert resp.status_code == 422 @pytest.mark.asyncio From 8d776e8a2c8eca5f58bc9caefac604aaab2f029b Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 11 Aug 2026 19:17:58 +0900 Subject: [PATCH 19/21] docs(intent): record ANALYZE confidence policy (#32) --- docs/analyses-contract.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/analyses-contract.md b/docs/analyses-contract.md index 877baec..4a46671 100644 --- a/docs/analyses-contract.md +++ b/docs/analyses-contract.md @@ -143,16 +143,15 @@ PLAN에는 `plannedIntent`, `plannedWorkflowId`, Worker context를 보내지 않 Candidate의 `workflowId`는 `plannedWorkflowId`와 반드시 같아야 한다. ANALYZE는 모델을 재호출하지 않고 Server가 confidence를 다시 보내지 않으므로 AI는 새 점수를 만들지 않는다. -## Server #138 확인·대기 항목 - -PR #138은 A.X의 nullable confidence, nullable evidence, ANALYZE의 -`providerAttemptCount=0`을 이미 허용한다. 따라서 A.X 대표 Intent 경로는 이 계약과 맞는다. - -남은 경계 사례는 BERT 경로다. Server는 ANALYZE Candidate confidence가 PLAN confidence와 -같기를 요구하지만, HTTP 요청에는 `plannedIntent`, `plannedWorkflowId`만 보내므로 AI가 PLAN의 -BERT 점수를 알 수 없다. AI는 재분류하거나 점수를 만들지 않고 Candidate confidence를 null로 -유지한다. Server는 BERT 경로에서 Candidate confidence 비교를 제거하거나, 별도 계약 합의 후 -PLAN confidence를 ANALYZE에 전달해야 한다. +## Server #138 확정 정책 + +- PLAN의 confidence는 Intent 분류 결과다. +- Server는 PLAN의 BERT confidence, confidenceSource, bertRoutingScore를 실행 이력에 보존한다. +- ANALYZE 요청에는 `plannedIntent`, `plannedWorkflowId`만 전달한다. +- AI는 Intent 모델을 재호출하지 않고 Candidate confidence를 null로 반환한다. +- Server는 Candidate confidence와 PLAN confidence를 비교하지 않으며 nullable을 허용한다. +- Candidate의 workflowId는 plannedWorkflowId와 반드시 같아야 한다. +- PLAN 결정을 재사용한 ANALYZE의 providerAttemptCount는 0이다. ## Intent 운영 설정 From ec3772c7d42612aa8901e1f085f512cbd9dd3267 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 11 Aug 2026 20:04:10 +0900 Subject: [PATCH 20/21] fix(intent): harden workflow routing and readiness (#32) --- .env.example | 3 + app/agents/intent/hybrid.py | 9 ++ app/agents/intent/service.py | 131 +++++++++++++----- app/agents/pipeline.py | 22 ++- app/agents/workflow/__init__.py | 4 +- app/agents/workflow/service.py | 102 +++++++++++++- app/api/routes/analyses.py | 22 ++- app/api/schemas/analyses.py | 11 +- app/core/config.py | 14 ++ app/main.py | 4 +- app/runtime.py | 35 +++++ docs/analyses-contract.md | 37 ++++- .../analyses/response_context_required.json | 2 +- examples/analyses/response_needs_info.json | 2 +- examples/analyses/response_out_of_scope.json | 20 +++ .../analyses/response_review_required.json | 2 +- tests/agents/test_analysis_pipeline.py | 9 +- tests/agents/test_intent_agent.py | 6 + tests/agents/test_intent_hybrid.py | 98 +++++++++++++ tests/agents/test_workflow_agent.py | 40 +++++- tests/api/test_analyses_endpoint.py | 95 ++++++++++++- tests/contracts/test_analyses_fixtures.py | 12 +- tests/test_runtime.py | 78 +++++++++++ 23 files changed, 695 insertions(+), 63 deletions(-) create mode 100644 app/runtime.py create mode 100644 examples/analyses/response_out_of_scope.json create mode 100644 tests/test_runtime.py diff --git a/.env.example b/.env.example index b082ce9..8620911 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,9 @@ FOWOCO_INTENT_AX_ADAPTER_PATH=fowoco/ax-intent-qlora # FOWOCO_INTENT_AX_ADAPTER_REVISION= FOWOCO_INTENT_ENABLE_AX=false FOWOCO_INTENT_DEVICE=cpu +# 운영에서는 트래픽 수신 전에 모델 로딩·첫 추론 완료 +FOWOCO_INTENT_WARMUP_ON_START=true +FOWOCO_INTENT_WARMUP_REQUIRED=false # 비공개 HF 모델 접근용 토큰 (실제 토큰으로 교체 필요) FOWOCO_HF_TOKEN=hf_YOUR_HUGGINGFACE_TOKEN_HERE diff --git a/app/agents/intent/hybrid.py b/app/agents/intent/hybrid.py index e5fbd20..ad72605 100644 --- a/app/agents/intent/hybrid.py +++ b/app/agents/intent/hybrid.py @@ -72,6 +72,15 @@ def __init__( except Exception: logger.exception("A.X load failed — BERT-only degraded mode") + # readiness 전에 각 활성 모델의 첫 forward/generate를 완료한다. + def warmup(self) -> None: + self.bert.predict("체류기간 연장 준비해줘") + if not self.ax_enabled: + return + if self.ax is None: + raise RuntimeError("A.X is enabled but unavailable") + self.ax.predict("여권 사본을 요청해줘") + # instruction → 정규화 Intent 예측 def predict(self, instruction: str) -> HybridIntentPrediction: probs, margin, bert_intents = self.bert.predict(instruction) diff --git a/app/agents/intent/service.py b/app/agents/intent/service.py index fdfc803..f7b4d6e 100644 --- a/app/agents/intent/service.py +++ b/app/agents/intent/service.py @@ -5,8 +5,11 @@ import logging import re from dataclasses import dataclass, field +from threading import Lock from typing import Protocol +from app.agents.workflow import select_workflow_id + logger = logging.getLogger(__name__) # Knowledge workflow_catalog.yaml: intent → workflow id (동일 intent면 catalog 등장 순) @@ -60,10 +63,12 @@ def public_workflow_id( return internal_workflow_id -# 의도 후보 WF에 constraint 적용 (다후보는 catalog 순 첫 id) +# 의도 후보 WF에 constraint와 발화 업무 신호를 적용 def resolve_workflow_id( intent: str, workflow_constraints: list[str] | None, + *, + instruction: str = "", ) -> str: candidate_workflows = list(INTENT_TO_WORKFLOWS.get(intent, [])) if workflow_constraints: @@ -75,7 +80,14 @@ def resolve_workflow_id( is_workflow_catalog_id(c) for c in workflow_constraints ): candidate_workflows = [] - return candidate_workflows[0] if candidate_workflows else "" + return ( + select_workflow_id( + intent=intent, + instruction=instruction, + candidate_workflow_ids=candidate_workflows, + ) + or "" + ) @dataclass @@ -98,6 +110,9 @@ class IntentResult: # 교체 가능한 Intent 분류기 계약 class IntentClassifier(Protocol): + # startup 시 모델 로딩·첫 추론을 완료한다. + def warmup(self) -> None: ... + # 모델 초기화·A.X 가용성 진단 (조회 시 lazy load 금지) def runtime_status(self) -> dict[str, object]: ... @@ -113,6 +128,10 @@ def classify( # 재갱신 Intent 고정 — 슬롯은 Server worker 시드에 맡김 (발화 정규식 추출 없음) class FixedExpiryRenewalIntentAgent: + # 고정 규칙 모드는 외부 모델 초기화가 없다. + def warmup(self) -> None: + return None + # 운영 상태 — 모델 기능이 꺼진 고정 규칙 모드 def runtime_status(self) -> dict[str, object]: return { @@ -121,6 +140,8 @@ def runtime_status(self) -> dict[str, object]: "initialized": True, "bertAvailable": False, "axAvailable": False, + "ready": True, + "warmupCompleted": True, "degraded": False, "promptVersion": "not-applicable", } @@ -132,12 +153,15 @@ def classify( *, workflow_constraints: list[str] | None = None, ) -> IntentResult: - del instruction intent = "EXPIRY_RENEWAL" return IntentResult( intent=intent, confidence=1.0, - workflow_id=resolve_workflow_id(intent, workflow_constraints), + workflow_id=resolve_workflow_id( + intent, + workflow_constraints, + instruction=instruction, + ), model_provider="internal", model_name="fixed-expiry-renewal", model_version="rules", @@ -170,6 +194,9 @@ class HybridHfIntentAgent: def __init__(self, pipeline: object | None = None) -> None: self._pipeline = pipeline self._load_error: str | None = None + self._load_lock = Lock() + self._warmup_completed = pipeline is not None + self._warmup_error: str | None = None # 설정 기반 HybridIntentPipeline 확보 def _ensure_pipeline(self) -> object | None: @@ -177,35 +204,58 @@ def _ensure_pipeline(self) -> object | None: return self._pipeline if self._load_error is not None: return None + with self._load_lock: + if self._pipeline is not None: + return self._pipeline + if self._load_error is not None: + return None + try: + import os + + from app.core.config import get_settings + + from .hybrid import HybridIntentPipeline + + settings = get_settings() + token = settings.hf_token or os.environ.get("HF_TOKEN") + self._pipeline = HybridIntentPipeline( + bert_model_dir=settings.intent_bert_model_dir, + bert_model_revision=settings.intent_bert_model_revision, + device=settings.intent_device, + label_prob_threshold=settings.intent_label_prob_threshold, + margin_threshold=settings.intent_margin_threshold, + max_trained_labels=settings.intent_max_trained_labels, + hf_token=token, + enable_ax=settings.intent_enable_ax, + ax_base_model_name=settings.intent_ax_base_model, + ax_base_revision=settings.intent_ax_base_revision, + ax_adapter_path=settings.intent_ax_adapter_path, + ax_adapter_revision=settings.intent_ax_adapter_revision, + ax_max_new_tokens=settings.intent_ax_max_new_tokens, + ) + except Exception as exc: + self._load_error = str(exc) + logger.exception("HF Intent pipeline load failed — fixed EXPIRY fallback") + return None + return self._pipeline + + # startup에서 BERT/A.X 로딩과 첫 추론을 끝내 cold start를 요청 경로에서 제거한다. + def warmup(self) -> None: + pipeline = self._ensure_pipeline() + if pipeline is None: + self._warmup_error = self._load_error or "Intent pipeline unavailable" + raise RuntimeError(self._warmup_error) try: - import os - - from app.core.config import get_settings - - from .hybrid import HybridIntentPipeline - - settings = get_settings() - token = settings.hf_token or os.environ.get("HF_TOKEN") - self._pipeline = HybridIntentPipeline( - bert_model_dir=settings.intent_bert_model_dir, - bert_model_revision=settings.intent_bert_model_revision, - device=settings.intent_device, - label_prob_threshold=settings.intent_label_prob_threshold, - margin_threshold=settings.intent_margin_threshold, - max_trained_labels=settings.intent_max_trained_labels, - hf_token=token, - enable_ax=settings.intent_enable_ax, - ax_base_model_name=settings.intent_ax_base_model, - ax_base_revision=settings.intent_ax_base_revision, - ax_adapter_path=settings.intent_ax_adapter_path, - ax_adapter_revision=settings.intent_ax_adapter_revision, - ax_max_new_tokens=settings.intent_ax_max_new_tokens, - ) + warmup = getattr(pipeline, "warmup", None) + if callable(warmup): + warmup() + else: + pipeline.predict("체류기간 연장 준비해줘") # type: ignore[attr-defined] except Exception as exc: - self._load_error = str(exc) - logger.exception("HF Intent pipeline load failed — fixed EXPIRY fallback") - return None - return self._pipeline + self._warmup_error = str(exc) + raise + self._warmup_completed = True + self._warmup_error = None # lazy-load 상태를 바꾸지 않고 readiness 진단값을 반환 def runtime_status(self) -> dict[str, object]: @@ -229,7 +279,13 @@ def runtime_status(self) -> dict[str, object]: "initialized": initialized, "bertAvailable": bert_available, "axAvailable": ax_available, + "ready": self._warmup_completed + and bert_available + and (not ax_enabled or ax_available) + and self._warmup_error is None, + "warmupCompleted": self._warmup_completed, "degraded": self._load_error is not None + or self._warmup_error is not None or (initialized and ax_enabled and not ax_available), "promptVersion": ( AX_INTENT_PROMPT_VERSION @@ -253,6 +309,10 @@ def classify( result.model_version = "fallback" return result prediction = pipeline.predict(instruction) # type: ignore[attr-defined] + self._warmup_completed = True + self._warmup_error = ( + "Intent inference degraded" if prediction.degraded else None + ) primary_intent, _ = _primary_intent( prediction.intents, prediction.scores ) @@ -272,10 +332,15 @@ def classify( if prediction.selected_model == "AX" else settings.intent_bert_model_dir ) + evidence = (prediction.evidence or {}).get(representative) return IntentResult( intent=representative, confidence=None if is_ax else float(bert_score or 0.0), - workflow_id=resolve_workflow_id(representative, workflow_constraints), + workflow_id=resolve_workflow_id( + representative, + workflow_constraints, + instruction=evidence or instruction, + ), extracted_slots={}, model_provider="huggingface", model_name=model_name, @@ -283,7 +348,7 @@ def classify( prompt_version=prediction.prompt_version, confidence_source="UNAVAILABLE" if is_ax else "BERT", bert_routing_score=(float(bert_score) if bert_score is not None else None), - evidence=(prediction.evidence or {}).get(representative), + evidence=evidence, ) diff --git a/app/agents/pipeline.py b/app/agents/pipeline.py index 3e766c0..51965e4 100644 --- a/app/agents/pipeline.py +++ b/app/agents/pipeline.py @@ -1,4 +1,4 @@ -# Analyses 파이프라인 — PLAN(CONTEXT_REQUIRED) → ANALYZE(NEEDS_INFO|REVIEW_REQUIRED) +# Analyses 파이프라인 — PLAN(CONTEXT_REQUIRED|OUT_OF_SCOPE) → ANALYZE from __future__ import annotations @@ -161,12 +161,22 @@ def run(self, request: AnalysisRequest) -> AnalysisResponse: def _run_plan(self, request: AnalysisRequest) -> AnalysisResponse: instruction = request.analysis_input.instruction intent_result = self._intent.classify(instruction) - workflow_id = intent_result.workflow_id or "" if intent_result.intent == "OUT_OF_SCOPE": - field_keys = ["worker_id"] - else: - required = self._required_slots_for(workflow_id) - field_keys = list(required) if required else ["worker_id", "stay_expiry_date"] + return AnalysisResponse( + request_id=request.request_id, + outcome="OUT_OF_SCOPE", + context_requirement=None, + questions=[], + candidates=[], + validation_errors=[], + versions=_versions(intent_result), + provider_attempt_count=1, + latency_ms=0, + ) + + workflow_id = intent_result.workflow_id or "" + required = self._required_slots_for(workflow_id) + field_keys = list(required) if required else ["worker_id", "stay_expiry_date"] return AnalysisResponse( request_id=request.request_id, diff --git a/app/agents/workflow/__init__.py b/app/agents/workflow/__init__.py index c313261..5d0897c 100644 --- a/app/agents/workflow/__init__.py +++ b/app/agents/workflow/__init__.py @@ -1,5 +1,5 @@ # Workflow Agent — Knowledge Catalog 조회 -from .service import WorkflowAgent +from .service import WorkflowAgent, select_workflow_id -__all__ = ["WorkflowAgent"] +__all__ = ["WorkflowAgent", "select_workflow_id"] diff --git a/app/agents/workflow/service.py b/app/agents/workflow/service.py index f704a43..a611860 100644 --- a/app/agents/workflow/service.py +++ b/app/agents/workflow/service.py @@ -4,6 +4,55 @@ from dataclasses import dataclass, field +_DEFAULT_WORKFLOW_BY_INTENT: dict[str, str] = { + "WORKER_ONBOARDING": "WF-WRK-001", + "EXPIRY_RENEWAL": "WF-STY-001", + "DOCUMENT_REQUEST": "WF-DOC-001", + "PAYROLL_EXPLANATION": "WF-PAY-001", + "WORK_INSTRUCTION": "WF-INS-001", + "EMPLOYMENT_CHANGE": "WF-CHG-001", +} + +# 하나의 대표 Intent 아래 여러 Knowledge Workflow가 있을 때 사용하는 업무 신호다. +# Intent 모델을 다시 호출하지 않고 발화/evidence만으로 canonical Workflow를 고른다. +_WORKFLOW_ROUTING_TERMS: dict[str, tuple[str, ...]] = { + "WF-STY-001": ( + "체류기간 연장", + "체류 연장", + "체류기간", + "비자 연장", + "외국인등록증", + "체류", + "비자", + ), + "WF-CON-001": ( + "근로계약 갱신", + "근로계약", + "재계약", + "계약 종료", + "계약 만료", + "계약 갱신", + ), + "WF-DOC-001": ( + "여권 사본", + "등록증 사본", + "사본 요청", + "서류 요청", + "제출 요청", + "업로드", + "사본", + ), + "WF-ADM-001": ( + "재직증명서", + "경력증명서", + "증명서 발급", + "행정 서류", + "기관 제출", + "신고서", + "발급", + ), +} + _BUILTIN_CATALOG: dict[str, dict[str, object]] = { "WF-WRK-001": { "name": "근로자 등록·정보변경", @@ -69,6 +118,46 @@ } +def _normalized_text(value: str) -> str: + return "".join(value.casefold().split()) + + +# Intent 후보 안에서 발화 근거가 가장 강한 Workflow를 고른다. +# 매칭 신호가 없으면 catalog 순서가 아니라 명시된 MVP 기본 Workflow를 사용한다. +def select_workflow_id( + *, + intent: str, + instruction: str, + candidate_workflow_ids: list[str], +) -> str | None: + if not candidate_workflow_ids: + return None + if len(candidate_workflow_ids) == 1: + return candidate_workflow_ids[0] + + normalized = _normalized_text(instruction) + ranked: list[tuple[int, int, str]] = [] + for workflow_id in candidate_workflow_ids: + matches: list[tuple[str, int]] = [] + for term in _WORKFLOW_ROUTING_TERMS.get(workflow_id, ()): + normalized_term = _normalized_text(term) + position = normalized.find(normalized_term) + if position >= 0: + matches.append((normalized_term, position)) + if matches: + score = sum(len(term) for term, _ in matches) + first_position = min(position for _, position in matches) + ranked.append((score, -first_position, workflow_id)) + + if ranked: + return max(ranked)[2] + + default_workflow = _DEFAULT_WORKFLOW_BY_INTENT.get(intent) + if default_workflow in candidate_workflow_ids: + return default_workflow + return None + + @dataclass # 워크플로 카탈로그 한 건의 스냅샷 class WorkflowInfo: @@ -108,7 +197,11 @@ def list_workflows(self) -> list[WorkflowInfo]: # Intent와 선택적 constraint로 최적 워크플로 선택 def resolve_workflow( - self, intent: str, constraints: list[str] | None = None + self, + intent: str, + constraints: list[str] | None = None, + *, + instruction: str = "", ) -> WorkflowInfo | None: candidates = [ self.get_workflow(wid) @@ -122,4 +215,9 @@ def resolve_workflow( if constrained: candidates = constrained - return candidates[0] if candidates else None + selected_id = select_workflow_id( + intent=intent, + instruction=instruction, + candidate_workflow_ids=[candidate.workflow_id for candidate in candidates], + ) + return self.get_workflow(selected_id) if selected_id else None diff --git a/app/api/routes/analyses.py b/app/api/routes/analyses.py index 0a708db..b2e4620 100644 --- a/app/api/routes/analyses.py +++ b/app/api/routes/analyses.py @@ -1,6 +1,6 @@ # POST /internal/v1/analyses — Server가 호출하는 핵심 분석 API -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Response, status from app.agents.intent import IntentClassifier from app.agents.pipeline import AnalysisPipeline @@ -50,3 +50,23 @@ async def intent_status( ) -> IntentRuntimeStatus: status = intent_agent.runtime_status() return IntentRuntimeStatus.model_validate(status) + + +@router.get( + "/intent/readiness", + response_model=IntentRuntimeStatus, + summary="Intent 모델 readiness", + description="활성 Intent 모델의 warmup과 BERT/A.X 가용성이 확인된 경우에만 200 반환.", + responses={503: {"description": "Intent 모델이 아직 준비되지 않음"}}, + dependencies=[Depends(verify_internal_bearer)], +) +async def intent_readiness( + response: Response, + intent_agent: IntentClassifier = Depends(get_intent_agent), # noqa: B008 +) -> IntentRuntimeStatus: + runtime_status = IntentRuntimeStatus.model_validate( + intent_agent.runtime_status() + ) + if not runtime_status.ready: + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + return runtime_status diff --git a/app/api/schemas/analyses.py b/app/api/schemas/analyses.py index 66035e4..8ef82ef 100644 --- a/app/api/schemas/analyses.py +++ b/app/api/schemas/analyses.py @@ -7,10 +7,15 @@ from pydantic import BaseModel, Field, model_validator AnalysisPhase = Literal["PLAN", "ANALYZE"] -AnalysisOutcome = Literal["CONTEXT_REQUIRED", "NEEDS_INFO", "REVIEW_REQUIRED"] +AnalysisOutcome = Literal[ + "CONTEXT_REQUIRED", + "NEEDS_INFO", + "REVIEW_REQUIRED", + "OUT_OF_SCOPE", +] ConfidenceSource = Literal["MODEL", "BERT", "UNAVAILABLE"] -DEFAULT_CONTRACT_VERSION = "1.0.0" +DEFAULT_CONTRACT_VERSION = "1.1.0" DEFAULT_KNOWLEDGE_VERSION = "0.2.0" @@ -157,6 +162,8 @@ class IntentRuntimeStatus(BaseModel): initialized: bool bert_available: bool = Field(..., alias="bertAvailable") ax_available: bool = Field(..., alias="axAvailable") + ready: bool + warmup_completed: bool = Field(..., alias="warmupCompleted") degraded: bool prompt_version: str = Field(..., alias="promptVersion") diff --git a/app/core/config.py b/app/core/config.py index 322f47e..952bd52 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -58,6 +58,8 @@ class Settings(BaseSettings): intent_max_trained_labels: int = 3 intent_label_prob_threshold: float = 0.55 intent_ax_max_new_tokens: int = 96 + intent_warmup_on_start: bool = True + intent_warmup_required: bool = False # private HF 모델용. 미설정 시 환경변수 HF_TOKEN도 허용(코드에서 조회) hf_token: str | None = None @@ -99,6 +101,18 @@ def validate_enabled_ocr_settings(self) -> Self: raise ValueError(f"enabled OCR requires settings: {', '.join(missing)}") return self + @model_validator(mode="after") + def validate_intent_warmup_settings(self) -> Self: + if self.intent_warmup_required and not self.intent_warmup_on_start: + raise ValueError( + "intent_warmup_required requires intent_warmup_on_start=true" + ) + if self.intent_warmup_required and not self.intent_model_enabled: + raise ValueError( + "intent_warmup_required requires intent_model_enabled=true" + ) + return self + # Settings 싱글톤을 반환 @lru_cache diff --git a/app/main.py b/app/main.py index 75d7198..070e3d7 100644 --- a/app/main.py +++ b/app/main.py @@ -10,7 +10,7 @@ from app.api.routes.workflows import router as workflows_router from app.core.config import get_settings from app.documents.conversion import ConversionEngineUnavailableError -from app.ocr.runtime import create_ocr_lifespan +from app.runtime import create_app_lifespan class UTF8JSONResponse(JSONResponse): @@ -26,7 +26,7 @@ def create_app() -> FastAPI: debug=settings.debug, default_response_class=UTF8JSONResponse, openapi_tags=OPENAPI_TAGS_METADATA, - lifespan=create_ocr_lifespan(settings), + lifespan=create_app_lifespan(settings), ) @app.exception_handler(ConversionEngineUnavailableError) diff --git a/app/runtime.py b/app/runtime.py new file mode 100644 index 0000000..0a5a9ea --- /dev/null +++ b/app/runtime.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI +from starlette.concurrency import run_in_threadpool + +from app.api.dependencies import get_intent_agent +from app.ocr.runtime import create_ocr_lifespan + +logger = logging.getLogger(__name__) + + +# OCR 자원과 선택적 Intent 모델 warmup을 하나의 FastAPI lifespan으로 조립한다. +def create_app_lifespan(settings: Any) -> Callable[[FastAPI], Any]: + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + async with create_ocr_lifespan(settings)(app): + if settings.intent_model_enabled and settings.intent_warmup_on_start: + intent_agent = get_intent_agent() + try: + await run_in_threadpool(intent_agent.warmup) + except Exception as exc: + app.state.intent_warmup_error = str(exc) + logger.exception("Intent model warmup failed") + if settings.intent_warmup_required: + raise + else: + app.state.intent_warmup_completed = True + yield + + return lifespan diff --git a/docs/analyses-contract.md b/docs/analyses-contract.md index 4a46671..931af11 100644 --- a/docs/analyses-contract.md +++ b/docs/analyses-contract.md @@ -1,6 +1,6 @@ # Analyses Runtime 계약 (AI 소유) -Server PR #138의 `AiRuntimeHttpRequest`와 맞춘 계약이다. 계약 버전은 **1.0.0**이며, +Server PR #138의 `AiRuntimeHttpRequest`와 맞춘 계약이다. 계약 버전은 **1.1.0**이며, MVP에서는 발화 하나당 대표 Intent와 canonical Workflow 한 쌍만 처리한다. ## Endpoint와 흐름 @@ -8,8 +8,9 @@ MVP에서는 발화 하나당 대표 Intent와 canonical Workflow 한 쌍만 처 ```text POST /internal/v1/analyses -PLAN → Intent 모델 1회 → CONTEXT_REQUIRED - → Server가 대표 Intent/Workflow 저장 + DB 조회 +PLAN → Intent 모델 1회 → CONTEXT_REQUIRED | OUT_OF_SCOPE + → CONTEXT_REQUIRED: Server가 대표 Intent/Workflow 저장 + DB 조회 + → OUT_OF_SCOPE: Workflow/DB/ANALYZE 없이 종료 ANALYZE → PLAN 결정 재사용(모델 0회) → NEEDS_INFO | REVIEW_REQUIRED ``` @@ -60,7 +61,7 @@ PLAN에는 `plannedIntent`, `plannedWorkflowId`, Worker context를 보내지 않 "promptVersion": "knowledge-25e778ad", "contextPackVersion": "0.2.0", "workflowCatalogVersion": "0.2.0", - "contractVersion": "1.0.0" + "contractVersion": "1.1.0" }, "providerAttemptCount": 1, "latencyMs": 120 @@ -77,6 +78,26 @@ PLAN에는 `plannedIntent`, `plannedWorkflowId`, Worker context를 보내지 않 - `evidence`는 A.X의 원문 substring이다. BERT와 OUT_OF_SCOPE에서는 null일 수 있다. - evidence는 Slot이 아니므로 `extractedSlots`에 `evidence:*` key를 만들지 않는다. - A.X가 여러 Intent를 반환해도 MVP 응답은 원문 등장 순서의 첫 Intent만 사용한다. +- 같은 Intent에 여러 Knowledge Workflow가 있으면 발화/evidence의 업무 신호로 선택한다. +- `EXPIRY_RENEWAL`의 체류 신호는 `WF-STY-001`, 계약 신호는 `WF-CON-001`이다. + +## OUT_OF_SCOPE 응답 + +실행할 Workflow가 없는 발화는 DB context를 요청하지 않고 PLAN에서 즉시 종료한다. + +```json +{ + "requestId": "10000000-0000-0000-0000-000000000001", + "outcome": "OUT_OF_SCOPE", + "contextRequirement": null, + "questions": [], + "candidates": [], + "validationErrors": [], + "providerAttemptCount": 1 +} +``` + +Server는 `OUT_OF_SCOPE`에서 Workflow 검증·DB 조회·ANALYZE 호출을 수행하지 않는다. ## ANALYZE 요청 @@ -159,14 +180,17 @@ Candidate의 `workflowId`는 `plannedWorkflowId`와 반드시 같아야 한다. |---|---| | `FOWOCO_INTENT_MODEL_ENABLED=false` | `EXPIRY_RENEWAL` 고정 규칙 | | `FOWOCO_INTENT_MODEL_ENABLED=true` | HF BERT와 선택적 A.X 하이브리드 | +| `FOWOCO_INTENT_WARMUP_ON_START=true` | startup에서 BERT/A.X 로딩·첫 추론 | +| `FOWOCO_INTENT_WARMUP_REQUIRED=true` | warmup 실패 시 애플리케이션 startup 실패 | A.X 사용 이미지는 `intent-ax` extra를 설치한다. 운영 장치에 맞춰 `FOWOCO_INTENT_DEVICE`를 설정하고 private HF Token은 Kubernetes Secret으로 주입한다. BERT/Base/Adapter의 `*_REVISION`은 immutable Hugging Face commit SHA로 고정한다. `GET /internal/v1/intent/status`에서 모델 설정, lazy-load 상태, BERT/A.X 가용성과 -`promptVersion`을 확인한다. 배포 smoke PLAN 후 `axAvailable=true`, -`promptVersion=knowledge-25e778ad`를 확인한다. +`promptVersion`을 확인한다. Kubernetes readiness는 +`GET /internal/v1/intent/readiness`의 200 응답을 사용한다. 운영에서는 warmup 후 +`ready=true`, `axAvailable=true`, `promptVersion=knowledge-25e778ad`를 확인한다. ## Fixtures @@ -177,3 +201,4 @@ BERT/Base/Adapter의 `*_REVISION`은 immutable Hugging Face commit SHA로 고정 | `examples/analyses/request_analyze.json` | ANALYZE 요청 | | `examples/analyses/response_needs_info.json` | NEEDS_INFO | | `examples/analyses/response_review_required.json` | REVIEW_REQUIRED | +| `examples/analyses/response_out_of_scope.json` | OUT_OF_SCOPE | diff --git a/examples/analyses/response_context_required.json b/examples/analyses/response_context_required.json index d8f7f66..c26370a 100644 --- a/examples/analyses/response_context_required.json +++ b/examples/analyses/response_context_required.json @@ -28,7 +28,7 @@ "promptVersion": "knowledge-25e778ad", "contextPackVersion": "0.2.0", "workflowCatalogVersion": "0.2.0", - "contractVersion": "1.0.0" + "contractVersion": "1.1.0" }, "providerAttemptCount": 1, "latencyMs": 120 diff --git a/examples/analyses/response_needs_info.json b/examples/analyses/response_needs_info.json index f858166..cb2195f 100644 --- a/examples/analyses/response_needs_info.json +++ b/examples/analyses/response_needs_info.json @@ -18,7 +18,7 @@ "promptVersion": "not-applicable", "contextPackVersion": "0.2.0", "workflowCatalogVersion": "0.2.0", - "contractVersion": "1.0.0" + "contractVersion": "1.1.0" }, "providerAttemptCount": 0, "latencyMs": 180 diff --git a/examples/analyses/response_out_of_scope.json b/examples/analyses/response_out_of_scope.json new file mode 100644 index 0000000..0325687 --- /dev/null +++ b/examples/analyses/response_out_of_scope.json @@ -0,0 +1,20 @@ +{ + "requestId": "10000000-0000-0000-0000-000000000001", + "outcome": "OUT_OF_SCOPE", + "contextRequirement": null, + "questions": [], + "candidates": [], + "validationErrors": [], + "versions": { + "agentVersion": "0.1.0", + "modelProvider": "huggingface", + "modelName": "fowoco/klue-roberta-base-intent-classifier", + "modelVersion": "BERT", + "promptVersion": "not-applicable", + "contextPackVersion": "0.2.0", + "workflowCatalogVersion": "0.2.0", + "contractVersion": "1.1.0" + }, + "providerAttemptCount": 1, + "latencyMs": 15 +} diff --git a/examples/analyses/response_review_required.json b/examples/analyses/response_review_required.json index 0ecea2a..7a60d89 100644 --- a/examples/analyses/response_review_required.json +++ b/examples/analyses/response_review_required.json @@ -26,7 +26,7 @@ "promptVersion": "not-applicable", "contextPackVersion": "0.2.0", "workflowCatalogVersion": "0.2.0", - "contractVersion": "1.0.0" + "contractVersion": "1.1.0" }, "providerAttemptCount": 0, "latencyMs": 245 diff --git a/tests/agents/test_analysis_pipeline.py b/tests/agents/test_analysis_pipeline.py index 30180f7..4a3386d 100644 --- a/tests/agents/test_analysis_pipeline.py +++ b/tests/agents/test_analysis_pipeline.py @@ -99,7 +99,7 @@ def test_plan_returns_single_context_decision_without_fake_evidence_slot() -> No assert "arc_status" in ctx.required_field_keys -def test_plan_out_of_scope_requests_worker_id_only() -> None: +def test_plan_out_of_scope_terminates_without_context_lookup() -> None: pipe = AnalysisPipeline(intent_agent=_FakeIntent(intent="OUT_OF_SCOPE", confidence=0.7)) res = pipe.run( AnalysisRequest( @@ -109,8 +109,11 @@ def test_plan_out_of_scope_requests_worker_id_only() -> None: ) ) - assert res.context_requirement is not None - assert res.context_requirement.required_field_keys == ["worker_id"] + assert res.outcome == "OUT_OF_SCOPE" + assert res.context_requirement is None + assert res.questions == [] + assert res.candidates == [] + assert res.provider_attempt_count == 1 def test_analyze_requires_planned_intent_and_workflow() -> None: diff --git a/tests/agents/test_intent_agent.py b/tests/agents/test_intent_agent.py index 0d01379..25e0512 100644 --- a/tests/agents/test_intent_agent.py +++ b/tests/agents/test_intent_agent.py @@ -28,6 +28,12 @@ def test_fixed_respects_workflow_constraints() -> None: assert result.workflow_id == "WF-CON-001" +def test_fixed_routes_contract_renewal_without_catalog_order_fallback() -> None: + agent = FixedExpiryRenewalIntentAgent() + result = agent.classify("근로계약 종료 전에 재계약 준비해줘") + assert result.workflow_id == "WF-CON-001" + + def test_build_intent_agent_is_fixed() -> None: assert isinstance(build_intent_agent(), FixedExpiryRenewalIntentAgent) diff --git a/tests/agents/test_intent_hybrid.py b/tests/agents/test_intent_hybrid.py index 5d47425..03a6fb8 100644 --- a/tests/agents/test_intent_hybrid.py +++ b/tests/agents/test_intent_hybrid.py @@ -1,5 +1,8 @@ # HF Intent 에이전트·대표 Intent 선택 단위 테스트 +from concurrent.futures import ThreadPoolExecutor +from threading import Event, Lock + from app.agents.intent.guardrail import HRRoutingGuardrail from app.agents.intent.hybrid import HybridIntentPipeline, HybridIntentPrediction from app.agents.intent.prompts import AX_INTENT_PROMPT_VERSION @@ -63,6 +66,23 @@ def predict(self, instruction: str) -> HybridIntentPrediction: assert result.bert_routing_score == 0.93 +def test_hybrid_agent_routes_contract_workflow_from_instruction() -> None: + class _FakePipe: + def predict(self, instruction: str) -> HybridIntentPrediction: + del instruction + return HybridIntentPrediction( + intents=["EXPIRY_RENEWAL"], + scores={"EXPIRY_RENEWAL": 0.93}, + selected_model="BERT", + ) + + result = HybridHfIntentAgent(pipeline=_FakePipe()).classify( + "근로계약 종료 전에 재계약 준비해줘" + ) + + assert result.workflow_id == "WF-CON-001" + + # A.X는 Knowledge prompt의 발화문 등장 순서를 대표 Intent에도 유지 def test_hybrid_agent_preserves_ax_intent_order() -> None: class _FakePipe: @@ -117,6 +137,8 @@ def test_hybrid_runtime_status_reports_loaded_ax_and_prompt(monkeypatch) -> None "initialized": True, "bertAvailable": True, "axAvailable": True, + "ready": True, + "warmupCompleted": True, "degraded": False, "promptVersion": AX_INTENT_PROMPT_VERSION, } @@ -124,6 +146,61 @@ def test_hybrid_runtime_status_reports_loaded_ax_and_prompt(monkeypatch) -> None get_settings.cache_clear() +def test_hybrid_warmup_marks_agent_ready() -> None: + class _WarmablePipeline: + bert = object() + ax = object() + ax_enabled = True + + def __init__(self) -> None: + self.calls = 0 + + def warmup(self) -> None: + self.calls += 1 + + pipeline = _WarmablePipeline() + agent = HybridHfIntentAgent(pipeline=pipeline) + + agent.warmup() + + assert pipeline.calls == 1 + assert agent.runtime_status()["ready"] is True + assert agent.runtime_status()["warmupCompleted"] is True + + +def test_hybrid_loader_is_singleton_under_concurrent_first_requests( + monkeypatch, +) -> None: + started = Event() + release = Event() + counter_lock = Lock() + calls = 0 + + class _FakeHybridPipeline: + def __init__(self, **kwargs: object) -> None: + nonlocal calls + del kwargs + with counter_lock: + calls += 1 + started.set() + assert release.wait(timeout=2) + + monkeypatch.setattr( + "app.agents.intent.hybrid.HybridIntentPipeline", _FakeHybridPipeline + ) + agent = HybridHfIntentAgent() + + with ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit(agent._ensure_pipeline) + assert started.wait(timeout=2) + second = executor.submit(agent._ensure_pipeline) + release.set() + assert first.result(timeout=2) is not None + assert second.result(timeout=2) is not None + + assert calls == 1 + + def test_hybrid_loader_forwards_pinned_model_revisions(monkeypatch) -> None: captured: dict[str, object] = {} @@ -218,6 +295,27 @@ def predict(self, _instruction: str) -> list[dict[str, str]]: assert prediction.prompt_version == AX_INTENT_PROMPT_VERSION +def test_pipeline_warmup_runs_bert_and_enabled_ax() -> None: + calls: list[tuple[str, str]] = [] + + class _Bert: + def predict(self, instruction: str) -> None: + calls.append(("BERT", instruction)) + + class _Ax: + def predict(self, instruction: str) -> None: + calls.append(("AX", instruction)) + + pipeline = HybridIntentPipeline.__new__(HybridIntentPipeline) + pipeline.bert = _Bert() + pipeline.ax = _Ax() + pipeline.ax_enabled = True + + pipeline.warmup() + + assert [model for model, _ in calls] == ["BERT", "AX"] + + def test_pipeline_marks_fallback_when_ax_enabled_but_unavailable() -> None: class _Bert: device = "cpu" diff --git a/tests/agents/test_workflow_agent.py b/tests/agents/test_workflow_agent.py index f368e2d..c8c0358 100644 --- a/tests/agents/test_workflow_agent.py +++ b/tests/agents/test_workflow_agent.py @@ -27,7 +27,45 @@ def test_resolve_workflow_by_intent() -> None: agent = WorkflowAgent() wf = agent.resolve_workflow("EXPIRY_RENEWAL") assert wf is not None - assert wf.workflow_id in ("WF-STY-001", "WF-CON-001") + assert wf.workflow_id == "WF-STY-001" + + +def test_resolve_stay_workflow_from_instruction() -> None: + agent = WorkflowAgent() + wf = agent.resolve_workflow( + "EXPIRY_RENEWAL", + instruction="체류기간 연장 준비해줘", + ) + assert wf is not None + assert wf.workflow_id == "WF-STY-001" + + +def test_resolve_contract_workflow_from_instruction() -> None: + agent = WorkflowAgent() + wf = agent.resolve_workflow( + "EXPIRY_RENEWAL", + instruction="근로계약 종료 전에 재계약 준비해줘", + ) + assert wf is not None + assert wf.workflow_id == "WF-CON-001" + + +def test_resolve_document_and_administration_workflows() -> None: + agent = WorkflowAgent() + + document = agent.resolve_workflow( + "DOCUMENT_REQUEST", + instruction="여권 사본을 업로드해 달라고 요청해줘", + ) + administration = agent.resolve_workflow( + "DOCUMENT_REQUEST", + instruction="재직증명서를 발급해줘", + ) + + assert document is not None + assert document.workflow_id == "WF-DOC-001" + assert administration is not None + assert administration.workflow_id == "WF-ADM-001" def test_resolve_with_constraints() -> None: diff --git a/tests/api/test_analyses_endpoint.py b/tests/api/test_analyses_endpoint.py index 1700f1b..15fa0d7 100644 --- a/tests/api/test_analyses_endpoint.py +++ b/tests/api/test_analyses_endpoint.py @@ -3,11 +3,40 @@ import pytest from httpx import ASGITransport, AsyncClient +from app.agents.intent import IntentResult +from app.agents.pipeline import AnalysisPipeline +from app.api.dependencies import get_analysis_pipeline, get_intent_agent from app.main import app ANALYSES_PATH = "/internal/v1/analyses" +class _OutOfScopeIntent: + def warmup(self) -> None: + return None + + def runtime_status(self) -> dict[str, object]: + return {} + + def classify( + self, + instruction: str, + *, + workflow_constraints: list[str] | None = None, + ) -> IntentResult: + del instruction, workflow_constraints + return IntentResult( + intent="OUT_OF_SCOPE", + confidence=0.99, + workflow_id="", + model_provider="test", + model_name="out-of-scope", + model_version="1", + confidence_source="BERT", + bert_routing_score=0.99, + ) + + def _plan_body(instruction: str = "응웬반안 체류연장 준비해줘") -> dict: return { "requestId": "10000000-0000-0000-0000-000000000001", @@ -67,7 +96,7 @@ async def test_plan_returns_context_required() -> None: assert ctx["targetDisplayName"] == "응웬반안" assert "stay_expiry_date" in ctx["requiredFieldKeys"] assert "worker_id" in ctx["requiredFieldKeys"] - assert data["versions"]["contractVersion"] == "1.0.0" + assert data["versions"]["contractVersion"] == "1.1.0" assert data["versions"]["workflowCatalogVersion"] == "0.2.0" assert data["versions"]["modelProvider"] != "stub" assert data["versions"]["modelName"] != "stub" @@ -166,6 +195,28 @@ async def test_plan_fixed_intent_even_for_unrelated_instruction() -> None: assert data["contextRequirement"]["confidence"] == 1.0 +@pytest.mark.asyncio +async def test_plan_out_of_scope_terminates_without_workflow_or_context() -> None: + app.dependency_overrides[get_analysis_pipeline] = lambda: AnalysisPipeline( + intent_agent=_OutOfScopeIntent() + ) + try: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.post(ANALYSES_PATH, json=_plan_body("오늘 날씨 어때?")) + finally: + app.dependency_overrides.pop(get_analysis_pipeline, None) + + assert resp.status_code == 200 + data = resp.json() + assert data["outcome"] == "OUT_OF_SCOPE" + assert data["contextRequirement"] is None + assert data["questions"] == [] + assert data["candidates"] == [] + assert data["versions"]["contractVersion"] == "1.1.0" + + @pytest.mark.asyncio async def test_analyses_endpoint_in_openapi() -> None: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: @@ -188,11 +239,53 @@ async def test_intent_status_exposes_runtime_flags_without_loading_models() -> N "initialized": True, "bertAvailable": False, "axAvailable": False, + "ready": True, + "warmupCompleted": True, "degraded": False, "promptVersion": "not-applicable", } +@pytest.mark.asyncio +async def test_intent_readiness_returns_200_for_fixed_agent() -> None: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.get("/internal/v1/intent/readiness") + + assert resp.status_code == 200 + assert resp.json()["ready"] is True + + +@pytest.mark.asyncio +async def test_intent_readiness_returns_503_until_hybrid_warmup() -> None: + class _NotReadyIntent: + def runtime_status(self) -> dict[str, object]: + return { + "intentModelEnabled": True, + "axEnabled": True, + "initialized": False, + "bertAvailable": False, + "axAvailable": False, + "ready": False, + "warmupCompleted": False, + "degraded": False, + "promptVersion": "knowledge-test", + } + + app.dependency_overrides[get_intent_agent] = _NotReadyIntent + try: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.get("/internal/v1/intent/readiness") + finally: + app.dependency_overrides.pop(get_intent_agent, None) + + assert resp.status_code == 503 + assert resp.json()["ready"] is False + + @pytest.mark.asyncio async def test_analyses_rejects_legacy_masked_input() -> None: body = { diff --git a/tests/contracts/test_analyses_fixtures.py b/tests/contracts/test_analyses_fixtures.py index c3b7fb3..3c2b328 100644 --- a/tests/contracts/test_analyses_fixtures.py +++ b/tests/contracts/test_analyses_fixtures.py @@ -13,6 +13,7 @@ "response_context_required.json", "response_needs_info.json", "response_review_required.json", + "response_out_of_scope.json", ) @@ -30,7 +31,12 @@ def test_analysis_request_fixtures_parse(name: str) -> None: def test_analysis_response_fixtures_parse(name: str) -> None: raw = (_FIXTURES / name).read_text(encoding="utf-8") res = AnalysisResponse.model_validate_json(raw) - assert res.outcome in {"CONTEXT_REQUIRED", "NEEDS_INFO", "REVIEW_REQUIRED"} + assert res.outcome in { + "CONTEXT_REQUIRED", + "NEEDS_INFO", + "REVIEW_REQUIRED", + "OUT_OF_SCOPE", + } if res.outcome == "REVIEW_REQUIRED": assert res.candidates assert res.candidates[0].missing_slots == [] @@ -39,3 +45,7 @@ def test_analysis_response_fixtures_parse(name: str) -> None: assert res.candidates == [] if res.outcome == "CONTEXT_REQUIRED": assert res.context_requirement is not None + if res.outcome == "OUT_OF_SCOPE": + assert res.context_requirement is None + assert res.questions == [] + assert res.candidates == [] diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..6ff2c1b --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,78 @@ +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI + +from app import runtime +from app.core.config import Settings + + +def _settings(**overrides: object) -> SimpleNamespace: + values = { + "clova_ocr_enabled": False, + "intent_model_enabled": True, + "intent_warmup_on_start": True, + "intent_warmup_required": True, + } + values.update(overrides) + return SimpleNamespace(**values) + + +def test_intent_warmup_is_enabled_by_default_when_models_are_enabled() -> None: + assert Settings.model_fields["intent_warmup_on_start"].default is True + + +@pytest.mark.asyncio +async def test_app_lifespan_warms_intent_before_serving( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _IntentAgent: + def __init__(self) -> None: + self.calls = 0 + + def warmup(self) -> None: + self.calls += 1 + + intent_agent = _IntentAgent() + monkeypatch.setattr(runtime, "get_intent_agent", lambda: intent_agent) + app = FastAPI() + + async with runtime.create_app_lifespan(_settings())(app): + assert app.state.intent_warmup_completed is True + + assert intent_agent.calls == 1 + + +@pytest.mark.asyncio +async def test_app_lifespan_fails_when_required_warmup_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _IntentAgent: + def warmup(self) -> None: + raise RuntimeError("model unavailable") + + monkeypatch.setattr(runtime, "get_intent_agent", _IntentAgent) + app = FastAPI() + + with pytest.raises(RuntimeError, match="model unavailable"): + async with runtime.create_app_lifespan(_settings())(app): + pass + + assert app.state.intent_warmup_error == "model unavailable" + + +@pytest.mark.asyncio +async def test_app_lifespan_can_serve_degraded_when_warmup_is_optional( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _IntentAgent: + def warmup(self) -> None: + raise RuntimeError("model unavailable") + + monkeypatch.setattr(runtime, "get_intent_agent", _IntentAgent) + app = FastAPI() + + async with runtime.create_app_lifespan( + _settings(intent_warmup_required=False) + )(app): + assert app.state.intent_warmup_error == "model unavailable" From 35d92fa86bedeb756556b7dd95591f2c1c83ffa6 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 11 Aug 2026 20:45:48 +0900 Subject: [PATCH 21/21] fix(workflow): preserve renewal task workflow (#32) --- app/agents/workflow/service.py | 4 ++ app/agents/workflow_graph/graph.py | 8 ++- .../workflow_graph/nodes/language_stub.py | 6 ++- docs/analyses-contract.md | 2 +- docs/workflows-contract.md | 16 ++++++ tests/agents/test_workflow_adapters.py | 19 +++++++ tests/agents/test_workflow_agent.py | 10 ++++ tests/agents/test_workflow_graph.py | 50 ++++++++++++++++++- 8 files changed, 111 insertions(+), 4 deletions(-) diff --git a/app/agents/workflow/service.py b/app/agents/workflow/service.py index a611860..e452289 100644 --- a/app/agents/workflow/service.py +++ b/app/agents/workflow/service.py @@ -29,6 +29,10 @@ "근로계약 갱신", "근로계약", "재계약", + "취업활동기간 연장", + "취업 활동 기간 연장", + "고용허가기간 연장", + "고용 허가 기간 연장", "계약 종료", "계약 만료", "계약 갱신", diff --git a/app/agents/workflow_graph/graph.py b/app/agents/workflow_graph/graph.py index 0a61547..1cf67b7 100644 --- a/app/agents/workflow_graph/graph.py +++ b/app/agents/workflow_graph/graph.py @@ -13,10 +13,10 @@ from .nodes.actions import ( apply_supervisor, load_context, - mark_out_of_scope, mark_ask_hr, mark_ask_worker, mark_guide_placeholder, + mark_out_of_scope, route_from_supervisor, ) from .nodes.document_generator import DocumentGenerator @@ -50,6 +50,12 @@ def node_load(state: RenewalState) -> dict[str, Any]: ctx = load_context(state, lookup=db_lookup) merged: RenewalState = {**state, **ctx} # type: ignore[typeddict-item] analysis = language_sg.invoke(merged) + task_workflow_id = str(state.get("workflow_id") or "").strip() + if analysis.get("intent") == "OUT_OF_SCOPE": + analysis["workflow_id"] = "" + elif task_workflow_id: + # Renewal 실행은 이미 생성된 Server Task의 canonical Workflow를 따른다. + analysis["workflow_id"] = task_workflow_id return {**ctx, **analysis} def node_supervisor(state: RenewalState) -> dict[str, Any]: diff --git a/app/agents/workflow_graph/nodes/language_stub.py b/app/agents/workflow_graph/nodes/language_stub.py index 2bfca43..d147599 100644 --- a/app/agents/workflow_graph/nodes/language_stub.py +++ b/app/agents/workflow_graph/nodes/language_stub.py @@ -35,7 +35,11 @@ def __init__( # intent·slots·missing·가이드 문구 채움 def __call__(self, state: RenewalState) -> dict[str, Any]: - result = self._intent.classify(state["instruction"]) + task_workflow_id = str(state.get("workflow_id") or "").strip() + result = self._intent.classify( + state["instruction"], + workflow_constraints=[task_workflow_id] if task_workflow_id else None, + ) slots = {**state.get("slots", {}), **result.extracted_slots} if result.intent == "OUT_OF_SCOPE": diff --git a/docs/analyses-contract.md b/docs/analyses-contract.md index 931af11..4d5c1e6 100644 --- a/docs/analyses-contract.md +++ b/docs/analyses-contract.md @@ -79,7 +79,7 @@ PLAN에는 `plannedIntent`, `plannedWorkflowId`, Worker context를 보내지 않 - evidence는 Slot이 아니므로 `extractedSlots`에 `evidence:*` key를 만들지 않는다. - A.X가 여러 Intent를 반환해도 MVP 응답은 원문 등장 순서의 첫 Intent만 사용한다. - 같은 Intent에 여러 Knowledge Workflow가 있으면 발화/evidence의 업무 신호로 선택한다. -- `EXPIRY_RENEWAL`의 체류 신호는 `WF-STY-001`, 계약 신호는 `WF-CON-001`이다. +- `EXPIRY_RENEWAL`의 체류 신호는 `WF-STY-001`, 계약·재계약·취업활동기간 연장·고용허가기간 연장 신호는 `WF-CON-001`이다. ## OUT_OF_SCOPE 응답 diff --git a/docs/workflows-contract.md b/docs/workflows-contract.md index 668e818..c3efdd6 100644 --- a/docs/workflows-contract.md +++ b/docs/workflows-contract.md @@ -11,6 +11,22 @@ Server가 재갱신 LangGraph를 호출할 때 쓰는 Internal API 요약이다. AI는 Workflow/Task 행을 만들지 않는다. 판단 신호만 주고 Server가 반영한다. +## Server Task Workflow 재사용 + +Renewal 실행은 PLAN과 달리 Server에 이미 생성된 Task를 처리한다. 요청의 +`task.workflowId`가 있으면 Language/Intent 단계의 Workflow constraint로 사용하고, 정상 +응답에도 같은 canonical ID를 유지한다. + +| Task type | Workflow ID | +|---|---| +| `RECONTRACT` | `WF-CON-001` | +| `EMPLOYMENT_PERIOD_EXTENSION` | `WF-CON-001` | +| `STAY_PERIOD_EXTENSION` | `WF-STY-001` | + +외부 Language Node가 다른 Workflow를 반환해도 Renewal Graph는 Server Task의 Workflow를 +복원한다. `intent=OUT_OF_SCOPE`, `scenario=out_of_scope`인 종료 응답만 `workflowId=""`를 +반환한다. + ## Endpoint ```text diff --git a/tests/agents/test_workflow_adapters.py b/tests/agents/test_workflow_adapters.py index 118928c..1e191ee 100644 --- a/tests/agents/test_workflow_adapters.py +++ b/tests/agents/test_workflow_adapters.py @@ -64,6 +64,25 @@ def test_language_adapter_wraps_external_engine() -> None: assert update["guide_message"] +def test_renewal_overrides_language_workflow_with_server_task_workflow() -> None: + """외부 Language Node도 이미 확정된 Server Task Workflow를 바꾸지 못한다.""" + orch = RenewalOrchestrator(language_node=LanguageNodeAdapter(_FakeLanguage())) + state = orch.run( + request_id="r-task-workflow", + instruction="체류기간 연장 준비해줘", + task_id="task-contract", + worker_id="worker-001", + task={ + "task_id": "task-contract", + "workflow_id": "WF-CON-001", + "task_type": "RECONTRACT", + }, + ) + + assert state["intent"] == "EXPIRY_RENEWAL" + assert state["workflow_id"] == "WF-CON-001" + + def test_ocr_adapter_wraps_external_engine() -> None: """OcrNodeAdapter가 동료 엔진을 OcrNode로 노출한다.""" adapter = OcrNodeAdapter(_FakeOcr()) diff --git a/tests/agents/test_workflow_agent.py b/tests/agents/test_workflow_agent.py index c8c0358..47c5ef8 100644 --- a/tests/agents/test_workflow_agent.py +++ b/tests/agents/test_workflow_agent.py @@ -50,6 +50,16 @@ def test_resolve_contract_workflow_from_instruction() -> None: assert wf.workflow_id == "WF-CON-001" +def test_resolve_employment_extension_as_contract_workflow() -> None: + agent = WorkflowAgent() + wf = agent.resolve_workflow( + "EXPIRY_RENEWAL", + instruction="취업활동기간 연장 준비해줘", + ) + assert wf is not None + assert wf.workflow_id == "WF-CON-001" + + def test_resolve_document_and_administration_workflows() -> None: agent = WorkflowAgent() diff --git a/tests/agents/test_workflow_graph.py b/tests/agents/test_workflow_graph.py index 21fd60e..8972b2d 100644 --- a/tests/agents/test_workflow_graph.py +++ b/tests/agents/test_workflow_graph.py @@ -35,6 +35,55 @@ def test_expiry_renewal_routes_to_waiting_worker() -> None: assert "passport_number" in state["missing_slots"] +def test_renewal_preserves_server_task_workflow() -> None: + """Renewal 실행은 발화 재분류보다 Server Task Workflow를 우선한다.""" + orch = RenewalOrchestrator(lookup=InMemoryDb(), store=InMemoryDb()) + state = orch.run( + request_id="req-contract-task", + instruction="체류기간 연장 준비해줘", + task_id="task-contract", + worker_id="worker-001", + task={ + "task_id": "task-contract", + "workflow_id": "WF-CON-001", + "task_type": "RECONTRACT", + }, + ) + + assert state["intent"] == "EXPIRY_RENEWAL" + assert state["workflow_id"] == "WF-CON-001" + + +def test_out_of_scope_clears_server_task_workflow() -> None: + """OUT_OF_SCOPE에는 Server Task가 있어도 실행 Workflow를 반환하지 않는다.""" + + def out_of_scope_language(state: dict) -> dict: + return { + "intent": "OUT_OF_SCOPE", + "workflow_id": state.get("workflow_id"), + "confidence": 0.9, + "slots": state.get("slots", {}), + "missing_slots": [], + } + + orch = RenewalOrchestrator(language_node=out_of_scope_language) + state = orch.run( + request_id="req-out-of-scope-task", + instruction="오늘 날씨 어때?", + task_id="task-contract", + worker_id="worker-001", + task={ + "task_id": "task-contract", + "workflow_id": "WF-CON-001", + "task_type": "RECONTRACT", + }, + ) + + assert state["intent"] == "OUT_OF_SCOPE" + assert state["workflow_id"] == "" + assert state["outcome"] == "OUT_OF_SCOPE" + + def test_ask_hr_when_identity_filled_but_contract_missing() -> None: """신분은 있고 계약 슬롯만 비면 담당자 입력(NEEDS_INFO)로 간다.""" orch = RenewalOrchestrator() @@ -131,4 +180,3 @@ def test_supervisor_document_combo_on_waiting_worker() -> None: "partial_unknown", } assert state.get("case_signals") -