文件预览

profile_store.py

查看 Founder Signal 技能包中的文件内容。

文件内容

src/founder_signal/profile_store.py

"""Profile-first storage helpers for Founder Signal."""

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from .config import _validate_profile, load_profiles
from .models import FounderSignalConfig
from .review_output import review_url
from .runtime_paths import imported_config_path, latest_review_index_path, runtime_profile_path
from .setup import normalized_config_from_runtime_profile, save_profile_bundle


@dataclass(frozen=True)
class ProfileBundle:
    profile_id: str
    normalized_config: dict[str, Any]
    internal_profile: dict[str, Any]
    profile_path: Path
    canonical_config_path: Path
    canonical_config_present: bool


def load_profile_bundle(root_dir: Path, profile_id: str) -> ProfileBundle:
    profile_path = runtime_profile_path(root_dir, profile_id)
    if not profile_path.exists():
        raise FileNotFoundError(f"Profile '{profile_id}' was not found in {root_dir / 'profiles'}")
    internal_profile = json.loads(profile_path.read_text(encoding="utf-8"))
    canonical_path = imported_config_path(root_dir, profile_id)
    canonical_present = canonical_path.exists()
    if canonical_present:
        normalized = json.loads(canonical_path.read_text(encoding="utf-8"))
    else:
        normalized = normalized_config_from_runtime_profile(internal_profile)
    return ProfileBundle(
        profile_id=profile_id,
        normalized_config=normalized,
        internal_profile=internal_profile,
        profile_path=profile_path,
        canonical_config_path=canonical_path,
        canonical_config_present=canonical_present,
    )


def save_profile(root_dir: Path, normalized_config: dict[str, Any]) -> ProfileBundle:
    result = save_profile_bundle(root_dir=root_dir, normalized_config=normalized_config)
    return load_profile_bundle(root_dir, result.profile_id)


def sync_profile_bundle(root_dir: Path, profile_id: str) -> ProfileBundle:
    bundle = load_profile_bundle(root_dir, profile_id)
    save_profile_bundle(
        root_dir=root_dir,
        normalized_config=bundle.normalized_config,
        internal_profile=bundle.internal_profile,
    )
    return load_profile_bundle(root_dir, profile_id)


def rebuild_canonical_profile(root_dir: Path, profile_id: str) -> ProfileBundle:
    bundle = load_profile_bundle(root_dir, profile_id)
    rebuilt = normalized_config_from_runtime_profile(bundle.internal_profile)
    save_profile_bundle(
        root_dir=root_dir,
        normalized_config=rebuilt,
        internal_profile=bundle.internal_profile,
    )
    return load_profile_bundle(root_dir, profile_id)


def validate_profile_bundle(root_dir: Path, profile_id: str) -> list[str]:
    bundle = load_profile_bundle(root_dir, profile_id)
    config = FounderSignalConfig.from_dict(bundle.internal_profile)
    _validate_profile(config=config, profile_path=bundle.profile_path)
    return _profile_summary_lines(config, bundle=bundle, root_dir=root_dir)


def ensure_canonical_configs(root_dir: Path, *, selected_profile_id: str | None = None) -> list[str]:
    healed: list[str] = []
    for config, _ in load_profiles(root_dir, selected_profile_id=selected_profile_id, include_disabled=True):
        canonical_path = imported_config_path(root_dir, config.profile_id)
        if canonical_path.exists():
            continue
        rebuilt = normalized_config_from_runtime_profile(_runtime_payload(root_dir, config.profile_id))
        save_profile_bundle(root_dir=root_dir, normalized_config=rebuilt)
        healed.append(config.profile_id)
    return healed


def list_profiles_with_metadata(root_dir: Path) -> list[dict[str, Any]]:
    profiles = load_profiles(root_dir, include_disabled=True)
    latest_reviews = load_latest_review_urls(root_dir)
    items: list[dict[str, Any]] = []
    for config, profile_path in profiles:
        canonical_path = imported_config_path(root_dir, config.profile_id)
        items.append(
            {
                "profile_id": config.profile_id,
                "enabled": bool(config.enabled),
                "product_name": config.product_name,
                "platforms_enabled": sorted(config.platforms),
                "profile_path": profile_path,
                "canonical_config_path": canonical_path,
                "canonical_config_present": canonical_path.exists(),
                "latest_review_url": str((latest_reviews.get(config.profile_id) or {}).get("review_url") or ""),
            }
        )
    return items


def write_latest_review_url(
    root_dir: Path,
    *,
    profile_id: str,
    artifact: dict[str, Any],
) -> None:
    url = review_url(artifact)
    if not url:
        return
    index_path = latest_review_index_path(root_dir)
    index_path.parent.mkdir(parents=True, exist_ok=True)
    payload = load_latest_review_urls(root_dir)
    payload[profile_id] = {
        "review_url": url,
        "run_id": str(artifact.get("run_id") or ""),
        "updated_at": str(artifact.get("created_at") or ""),
        "product_name": str(artifact.get("product_name") or ""),
    }
    index_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")


def load_latest_review_urls(root_dir: Path) -> dict[str, dict[str, str]]:
    index_path = latest_review_index_path(root_dir)
    if not index_path.exists():
        return {}
    payload = json.loads(index_path.read_text(encoding="utf-8"))
    if not isinstance(payload, dict):
        return {}
    normalized: dict[str, dict[str, str]] = {}
    for profile_id, item in payload.items():
        if not isinstance(item, dict):
            continue
        normalized[str(profile_id)] = {
            "review_url": str(item.get("review_url") or ""),
            "run_id": str(item.get("run_id") or ""),
            "updated_at": str(item.get("updated_at") or ""),
            "product_name": str(item.get("product_name") or ""),
        }
    return normalized


def _runtime_payload(root_dir: Path, profile_id: str) -> dict[str, Any]:
    profile_path = runtime_profile_path(root_dir, profile_id)
    return json.loads(profile_path.read_text(encoding="utf-8"))


def _profile_summary_lines(
    config: FounderSignalConfig,
    *,
    bundle: ProfileBundle,
    root_dir: Path,
) -> list[str]:
    latest = load_latest_review_urls(root_dir).get(config.profile_id) or {}
    latest_url = str(latest.get("review_url") or "").strip()
    return [
        f"Profile: {config.profile_id}",
        f"Product: {config.product_name}",
        f"Enabled: {'yes' if config.enabled else 'no'}",
        f"Platforms: {', '.join(sorted(config.platforms)) or 'none'}",
        f"Canonical config stored: {'yes' if bundle.canonical_config_present else 'reconstructed from runtime profile'}",
        f"Latest review URL: {latest_url or 'None'}",
    ]