文件内容
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'}",
]