Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

350 lines
14 KiB
Python

"""Database seeder — Postgres-native.
Post-Part-2 cutover: writes template prompts/tools/agents/sources directly
into Postgres via the repository layer. No MongoDB dependencies.
The seeder is invoked by the ``python -m application.seed.commands init``
CLI (not at Flask app startup). All template rows are owned by the
sentinel user id ``__system__`` — kept in sync with the migration
backfill/cleanup-trigger sentinel so template ownership is predictable.
"""
import logging
import os
from typing import Dict, List, Optional
import yaml
from dotenv import load_dotenv
from application.agents.tools.tool_manager import ToolManager
from application.api.user.tasks import ingest_remote
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.prompts import PromptsRepository
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.user_tools import UserToolsRepository
from application.storage.db.session import db_readonly, db_session
load_dotenv()
tool_config = {}
tool_manager = ToolManager(config=tool_config)
# Sentinel user id for template rows (agents/prompts/sources/tools).
# Kept in sync with the Postgres backfill / cleanup-trigger sentinel so
# template ownership is predictable across the cutover.
SYSTEM_USER_ID = "__system__"
class DatabaseSeeder:
"""Postgres-backed seeder.
The constructor accepts an optional positional argument for back
compatibility with legacy callers that used to pass a Mongo ``db``
handle. The value is ignored — all persistence goes through the
Postgres repositories.
"""
def __init__(self, db=None):
self._legacy_db = db # unused; retained for call-site compatibility
self.system_user_id = SYSTEM_USER_ID
self.logger = logging.getLogger(__name__)
def seed_initial_data(self, config_path: str = None, force=False):
"""Main entry point for seeding all initial data."""
if not force and self._is_already_seeded():
self.logger.info("Database already seeded. Use force=True to reseed.")
return
config_path = config_path or os.path.join(
os.path.dirname(__file__), "config", "premade_agents.yaml"
)
try:
with open(config_path, "r") as f:
config = yaml.safe_load(f)
self._seed_from_config(config)
except Exception as e:
self.logger.error(f"Failed to load seeding config: {str(e)}")
raise
def _seed_from_config(self, config: Dict):
"""Seed all data from configuration."""
self.logger.info("Starting seeding...")
if not config.get("agents"):
self.logger.warning("No agents found in config")
return
for agent_config in config["agents"]:
try:
self.logger.info(f"Processing agent: {agent_config['name']}")
# 1. Handle Source
source_result = self._handle_source(agent_config)
if source_result is False:
self.logger.error(
f"Skipping agent {agent_config['name']} due to source ingestion failure"
)
continue
source_id = source_result
# 2. Handle Tools
tool_ids = self._handle_tools(agent_config)
if len(tool_ids) == 0:
self.logger.warning(
f"No valid tools for agent {agent_config['name']}"
)
# 3. Handle Prompt
prompt_id = self._handle_prompt(agent_config)
# 4. Create or update Agent
self._upsert_agent(agent_config, source_id, tool_ids, prompt_id)
except Exception as e:
self.logger.error(
f"Error processing agent {agent_config['name']}: {str(e)}"
)
continue
self.logger.info("Database seeding completed")
@staticmethod
def _coerce_uuid_fk(raw) -> Optional[str]:
"""Coerce sentinel/blank values to ``None`` for nullable UUID FK columns.
Mirrors the route-side handling in ``application/api/user/agents/routes.py``:
the literal string ``"default"``, empty string, and ``None`` all map
to ``None`` so the repository layer skips the column and Postgres
keeps the FK NULL (FKs are ``ON DELETE SET NULL``).
"""
if raw in (None, "", "default"):
return None
return str(raw)
def _upsert_agent(
self,
agent_config: Dict,
source_id: Optional[str],
tool_ids: List[str],
prompt_id: Optional[str],
) -> None:
"""Create or update a template agent owned by ``__system__``."""
name = agent_config["name"]
prompt_id_val = self._coerce_uuid_fk(
prompt_id if prompt_id is not None else agent_config.get("prompt_id")
)
folder_id_val = self._coerce_uuid_fk(agent_config.get("folder_id"))
workflow_id_val = self._coerce_uuid_fk(agent_config.get("workflow_id"))
source_id_val = self._coerce_uuid_fk(source_id)
agent_fields = {
"description": agent_config["description"],
"image": agent_config.get("image", ""),
"tools": [str(tid) for tid in tool_ids],
"agent_type": agent_config["agent_type"],
"prompt_id": prompt_id_val,
"chunks": agent_config.get("chunks", "0"),
"retriever": agent_config.get("retriever", ""),
}
if folder_id_val is not None:
agent_fields["folder_id"] = folder_id_val
if workflow_id_val is not None:
agent_fields["workflow_id"] = workflow_id_val
if source_id_val is not None:
agent_fields["source_id"] = source_id_val
with db_session() as conn:
repo = AgentsRepository(conn)
existing = self._find_system_agent_by_name(repo, name)
if existing:
self.logger.info(f"Updating existing agent: {name}")
repo.update(str(existing["id"]), self.system_user_id, agent_fields)
self.logger.info(f"Successfully updated agent: {name} (ID: {existing['id']})")
else:
self.logger.info(f"Creating new agent: {name}")
created = repo.create(
user_id=self.system_user_id,
name=name,
status="template",
**agent_fields,
)
self.logger.info(
f"Successfully created agent: {name} (ID: {created.get('id')})"
)
@staticmethod
def _find_system_agent_by_name(repo: AgentsRepository, name: str) -> Optional[dict]:
"""Find a system-owned agent by name among the template rows."""
for row in repo.list_for_user(SYSTEM_USER_ID):
if row.get("name") == name:
return row
return None
def _handle_source(self, agent_config: Dict):
"""Handle source ingestion and return a source id (UUID string) or ``None``/``False``."""
if not agent_config.get("source"):
self.logger.info(
"No source provided for agent - will create agent without source"
)
return None
source_config = agent_config["source"]
self.logger.info(f"Ingesting source: {source_config['url']}")
try:
with db_readonly() as conn:
existing = self._find_system_source_by_remote_url(
SourcesRepository(conn), source_config["url"]
)
if existing:
self.logger.info(f"Source already exists: {existing['id']}")
return existing["id"]
# Ingest new source using worker
task = ingest_remote.delay(
source_data=source_config["url"],
job_name=source_config["name"],
user=self.system_user_id,
loader=source_config.get("loader", "url"),
)
result = task.get(timeout=300)
if not task.successful():
raise Exception(f"Source ingestion failed: {result}")
source_id = None
if isinstance(result, dict) and "id" in result:
source_id = result["id"]
else:
raise Exception(f"Source ingestion result missing 'id': {result}")
self.logger.info(f"Source ingested successfully: {source_id}")
return source_id
except Exception as e:
self.logger.error(f"Failed to ingest source: {str(e)}")
return False
@staticmethod
def _find_system_source_by_remote_url(
repo: SourcesRepository, url: str
) -> Optional[dict]:
"""Scan system-owned sources for a row whose remote_data matches ``url``."""
# TODO(migration-postgres): push this into SourcesRepository once a
# remote_data search helper exists; today we keep the scan here to
# stay within this slice's boundaries.
try:
rows = repo.list_for_user(SYSTEM_USER_ID) # type: ignore[attr-defined]
except AttributeError:
return None
for row in rows:
remote = row.get("remote_data")
if remote == url:
return row
if isinstance(remote, dict) and remote.get("url") == url:
return row
return None
def _handle_tools(self, agent_config: Dict) -> List[str]:
"""Handle tool creation and return list of tool ids (UUID strings)."""
tool_ids: List[str] = []
if not agent_config.get("tools"):
return tool_ids
for tool_config in agent_config["tools"]:
try:
tool_name = tool_config["name"]
processed_config = self._process_config(tool_config.get("config", {}))
self.logger.info(f"Processing tool: {tool_name}")
with db_session() as conn:
repo = UserToolsRepository(conn)
existing = self._find_system_tool(
repo, tool_name, processed_config
)
if existing:
self.logger.info(f"Tool already exists: {existing['id']}")
tool_ids.append(existing["id"])
continue
created = repo.create(
user_id=self.system_user_id,
name=tool_name,
display_name=tool_config.get("display_name", tool_name),
description=tool_config.get("description", ""),
actions=tool_manager.tools[tool_name].get_actions_metadata(),
config=processed_config,
status=True,
)
tool_ids.append(created["id"])
self.logger.info(f"Created new tool: {created['id']}")
except Exception as e:
self.logger.error(f"Failed to process tool {tool_name}: {str(e)}")
continue
return tool_ids
@staticmethod
def _find_system_tool(
repo: UserToolsRepository, name: str, config: dict
) -> Optional[dict]:
"""Locate a system-owned tool by (name, config) among existing rows."""
existing = repo.find_by_user_and_name(SYSTEM_USER_ID, name)
if existing and existing.get("config") == config:
return existing
return None
def _handle_prompt(self, agent_config: Dict) -> Optional[str]:
"""Handle prompt creation and return prompt id (UUID string)."""
if not agent_config.get("prompt"):
return None
prompt_config = agent_config["prompt"]
prompt_name = prompt_config.get("name", f"{agent_config['name']} Prompt")
prompt_content = prompt_config.get("content", "")
if not prompt_content:
self.logger.warning(
f"No prompt content provided for agent {agent_config['name']}"
)
return None
self.logger.info(f"Processing prompt: {prompt_name}")
try:
with db_session() as conn:
repo = PromptsRepository(conn)
row = repo.find_or_create(
self.system_user_id, prompt_name, prompt_content
)
prompt_id = str(row["id"])
self.logger.info(f"Prompt ready: {prompt_id}")
return prompt_id
except Exception as e:
self.logger.error(f"Failed to process prompt {prompt_name}: {str(e)}")
return None
def _process_config(self, config: Dict) -> Dict:
"""Process config values to replace environment variables."""
processed = {}
for key, value in config.items():
if (
isinstance(value, str)
and value.startswith("${")
and value.endswith("}")
):
env_var = value[2:-1]
processed[key] = os.getenv(env_var, "")
else:
processed[key] = value
return processed
def _is_already_seeded(self) -> bool:
"""Check if premade (system-owned) agents already exist in Postgres."""
with db_readonly() as conn:
repo = AgentsRepository(conn)
return len(repo.list_for_user(SYSTEM_USER_ID)) > 0
@classmethod
def initialize_from_env(cls, worker=None):
"""Factory method to create seeder from environment.
Retained for back compatibility with existing call sites. The
Postgres connection is resolved lazily via the repository layer
(``application.storage.db.engine``), so no explicit wiring is
required here.
"""
return cls()