release: bump version to 0.66.0
This commit is contained in:
@@ -19,6 +19,7 @@ from app.api.v1 import (
|
||||
vessels,
|
||||
bgp,
|
||||
news,
|
||||
interactables,
|
||||
realtime_sources,
|
||||
system_control,
|
||||
tv,
|
||||
@@ -53,4 +54,5 @@ api_router.include_router(vessels.router, prefix="/vessels", tags=["vessels"])
|
||||
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
||||
api_router.include_router(interactables.router, prefix="/interactables", tags=["interactables"])
|
||||
api_router.include_router(realtime_sources.router, prefix="/realtime-sources", tags=["realtime-sources"])
|
||||
|
||||
@@ -3,6 +3,7 @@ from uuid import uuid4
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
@@ -47,8 +48,10 @@ from app.services.playground_chat_service import (
|
||||
stop_message,
|
||||
)
|
||||
from app.services.situational_alert_ai_brief import build_situational_alert_brief_request
|
||||
from app.services.business_logs import emit_business_log, exception_context
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__, service="api")
|
||||
|
||||
|
||||
@router.get("/provider/status", response_model=AIProviderStatusResponse)
|
||||
@@ -122,6 +125,16 @@ async def create_playground_message(
|
||||
provider_client: AIProviderClient = Depends(get_ai_provider_client),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.playground.message.create",
|
||||
message="Playground message creation requested",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context={"session_key": payload.session_key, "preset": payload.selected_preset_key},
|
||||
)
|
||||
return await create_turn(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
@@ -136,6 +149,16 @@ async def stop_playground_message(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.playground.message.stop",
|
||||
message="Playground message stop requested",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context={"session_key": payload.session_key, "message_id": payload.message_id},
|
||||
)
|
||||
return await stop_message(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
@@ -150,6 +173,16 @@ async def resend_playground_message(
|
||||
provider_client: AIProviderClient = Depends(get_ai_provider_client),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.playground.message.resend",
|
||||
message="Playground message resend requested",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context={"session_key": payload.session_key, "user_message_id": payload.user_message_id},
|
||||
)
|
||||
return await resend_turn(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
@@ -214,16 +247,70 @@ async def analyze_bgp_brief(
|
||||
anomaly_limit=payload.anomaly_limit,
|
||||
collector_limit=payload.collector_limit,
|
||||
)
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.brief.bgp.facts_collected",
|
||||
message="BGP brief facts collected",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
request_id=request_id,
|
||||
user_id=current_user.id,
|
||||
context={
|
||||
"incident_limit": payload.incident_limit,
|
||||
"anomaly_limit": payload.anomaly_limit,
|
||||
"collector_limit": payload.collector_limit,
|
||||
"fact_count": len(facts or []),
|
||||
},
|
||||
)
|
||||
brief_request.preferred_model = payload.preferred_model
|
||||
brief_request.thinking = payload.thinking
|
||||
|
||||
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||
return save_bgp_brief_record(
|
||||
analysis,
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.brief.bgp.start",
|
||||
message="BGP brief AI analysis started",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
request_id=request_id,
|
||||
facts=facts,
|
||||
context=context,
|
||||
user_id=current_user.id,
|
||||
context={"preferred_model": payload.preferred_model},
|
||||
)
|
||||
try:
|
||||
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||
record = save_bgp_brief_record(
|
||||
analysis,
|
||||
request_id=request_id,
|
||||
facts=facts,
|
||||
context=context,
|
||||
)
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.brief.bgp.completed",
|
||||
message="BGP brief AI analysis saved",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
request_id=request_id,
|
||||
user_id=current_user.id,
|
||||
context={"provider": analysis.provider, "model": analysis.model, "brief_id": record.id},
|
||||
)
|
||||
return record
|
||||
except Exception as exc:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.brief.bgp.failed",
|
||||
message="BGP brief AI analysis failed",
|
||||
category="ai",
|
||||
level="error",
|
||||
service="api",
|
||||
module=__name__,
|
||||
request_id=request_id,
|
||||
user_id=current_user.id,
|
||||
context=exception_context(exc, {"preferred_model": payload.preferred_model}),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/alerts/brief", response_model=AlertBriefResponse)
|
||||
@@ -242,17 +329,65 @@ async def analyze_alert_brief(
|
||||
db,
|
||||
alert_limit=payload.alert_limit,
|
||||
)
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.brief.alerts.facts_collected",
|
||||
message="Alert brief facts collected",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
request_id=request_id,
|
||||
user_id=current_user.id,
|
||||
context={"alert_limit": payload.alert_limit, "fact_count": len(facts or [])},
|
||||
)
|
||||
brief_request.preferred_model = payload.preferred_model
|
||||
brief_request.thinking = payload.thinking
|
||||
|
||||
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||
return AlertBriefResponse(
|
||||
**analysis.model_dump(),
|
||||
title=brief_request.title,
|
||||
objective=brief_request.objective,
|
||||
facts=facts,
|
||||
context=context,
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.brief.alerts.start",
|
||||
message="Alert brief AI analysis started",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
request_id=request_id,
|
||||
user_id=current_user.id,
|
||||
context={"preferred_model": payload.preferred_model},
|
||||
)
|
||||
try:
|
||||
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.brief.alerts.completed",
|
||||
message="Alert brief AI analysis completed",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
request_id=request_id,
|
||||
user_id=current_user.id,
|
||||
context={"provider": analysis.provider, "model": analysis.model},
|
||||
)
|
||||
return AlertBriefResponse(
|
||||
**analysis.model_dump(),
|
||||
title=brief_request.title,
|
||||
objective=brief_request.objective,
|
||||
facts=facts,
|
||||
context=context,
|
||||
)
|
||||
except Exception as exc:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.brief.alerts.failed",
|
||||
message="Alert brief AI analysis failed",
|
||||
category="ai",
|
||||
level="error",
|
||||
service="api",
|
||||
module=__name__,
|
||||
request_id=request_id,
|
||||
user_id=current_user.id,
|
||||
context=exception_context(exc, {"preferred_model": payload.preferred_model}),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/situational-alerts/brief", response_model=SituationalAlertBriefResponse)
|
||||
@@ -268,14 +403,62 @@ async def analyze_situational_alert_brief(
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
|
||||
brief_request, facts, context = await build_situational_alert_brief_request(db)
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.brief.situational_alerts.facts_collected",
|
||||
message="Situational alert brief facts collected",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
request_id=request_id,
|
||||
user_id=current_user.id,
|
||||
context={"fact_count": len(facts or [])},
|
||||
)
|
||||
brief_request.preferred_model = payload.preferred_model
|
||||
brief_request.thinking = payload.thinking
|
||||
|
||||
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||
return SituationalAlertBriefResponse(
|
||||
**analysis.model_dump(),
|
||||
title=brief_request.title,
|
||||
objective=brief_request.objective,
|
||||
facts=facts,
|
||||
context=context,
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.brief.situational_alerts.start",
|
||||
message="Situational alert brief AI analysis started",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
request_id=request_id,
|
||||
user_id=current_user.id,
|
||||
context={"preferred_model": payload.preferred_model},
|
||||
)
|
||||
try:
|
||||
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.brief.situational_alerts.completed",
|
||||
message="Situational alert brief AI analysis completed",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
request_id=request_id,
|
||||
user_id=current_user.id,
|
||||
context={"provider": analysis.provider, "model": analysis.model},
|
||||
)
|
||||
return SituationalAlertBriefResponse(
|
||||
**analysis.model_dump(),
|
||||
title=brief_request.title,
|
||||
objective=brief_request.objective,
|
||||
facts=facts,
|
||||
context=context,
|
||||
)
|
||||
except Exception as exc:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="ai.brief.situational_alerts.failed",
|
||||
message="Situational alert brief AI analysis failed",
|
||||
category="ai",
|
||||
level="error",
|
||||
service="api",
|
||||
module=__name__,
|
||||
request_id=request_id,
|
||||
user_id=current_user.id,
|
||||
context=exception_context(exc, {"preferred_model": payload.preferred_model}),
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
@@ -7,7 +6,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.cache import cache
|
||||
from app.core.logging import get_logger
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.security import get_current_user
|
||||
from app.core.data_sources import get_data_sources_config
|
||||
@@ -22,16 +21,26 @@ from app.models.user import User
|
||||
from app.models.vessel import AISRawObservation
|
||||
from app.services.vessel_ais_aggregation import VESSEL_AIS_SCHEMA
|
||||
from app.services.scheduler import (
|
||||
cancel_running_collector_now,
|
||||
get_latest_task_id_for_datasource,
|
||||
run_collector_now,
|
||||
sync_datasource_job,
|
||||
)
|
||||
from app.services.earth_layer_cache import invalidate_earth_layer_cache_for_source
|
||||
from app.services.data_jobs import (
|
||||
JOB_STATUS_CANCELLING,
|
||||
JOB_STATUS_QUEUED,
|
||||
JOB_STATUS_RUNNING,
|
||||
JOB_TYPE_CLEAR_CACHE,
|
||||
JOB_TYPE_CLEAR_DATA,
|
||||
JOB_TYPE_COLLECT,
|
||||
enqueue_datasource_job,
|
||||
get_active_datasource_job,
|
||||
request_cancel_datasource_task,
|
||||
)
|
||||
from app.services.business_logs import emit_business_log
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__, service="api")
|
||||
STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90
|
||||
|
||||
|
||||
PRODUCT_SOURCE_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("vessels", ("vessel", "ais")),
|
||||
("cables", ("cable", "landing", "telegeography", "arcgis", "fao")),
|
||||
@@ -115,7 +124,7 @@ async def _load_latest_running_tasks(
|
||||
_task_rank_column(CollectionTask.started_at),
|
||||
)
|
||||
.where(CollectionTask.datasource_id.in_(datasource_ids))
|
||||
.where(CollectionTask.status == "running")
|
||||
.where(CollectionTask.status.in_((JOB_STATUS_QUEUED, JOB_STATUS_RUNNING, JOB_STATUS_CANCELLING)))
|
||||
.subquery()
|
||||
)
|
||||
result = await db.execute(
|
||||
@@ -126,32 +135,6 @@ async def _load_latest_running_tasks(
|
||||
return {task.datasource_id: task for task in result.scalars().all()}
|
||||
|
||||
|
||||
async def _load_latest_task_ids(
|
||||
db: AsyncSession,
|
||||
datasource_ids: list[int],
|
||||
) -> dict[int, int]:
|
||||
if not datasource_ids:
|
||||
return {}
|
||||
|
||||
ranked_tasks = (
|
||||
select(
|
||||
CollectionTask.id.label("task_id"),
|
||||
CollectionTask.datasource_id.label("datasource_id"),
|
||||
func.row_number().over(
|
||||
partition_by=CollectionTask.datasource_id,
|
||||
order_by=CollectionTask.id.desc(),
|
||||
).label("row_num"),
|
||||
)
|
||||
.where(CollectionTask.datasource_id.in_(datasource_ids))
|
||||
.subquery()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(ranked_tasks.c.datasource_id, ranked_tasks.c.task_id)
|
||||
.where(ranked_tasks.c.row_num == 1)
|
||||
)
|
||||
return {datasource_id: task_id for datasource_id, task_id in result.all()}
|
||||
|
||||
|
||||
async def _load_latest_tasks(
|
||||
db: AsyncSession,
|
||||
datasource_ids: list[int],
|
||||
@@ -303,8 +286,11 @@ def serialize_datasource_row(
|
||||
"last_run": to_iso8601_utc(last_run_at),
|
||||
"last_run_at": to_iso8601_utc(last_run_at),
|
||||
"last_status": last_status,
|
||||
"is_running": running_task is not None,
|
||||
"is_running": running_task is not None and running_task.task_type == JOB_TYPE_COLLECT,
|
||||
"is_task_active": running_task is not None,
|
||||
"task_status": running_task.status if running_task else None,
|
||||
"task_id": display_task.id if display_task else None,
|
||||
"task_type": display_task.task_type if display_task else None,
|
||||
"progress": display_task.progress if display_task else None,
|
||||
"phase": display_task.phase if display_task else None,
|
||||
"phase_progress": display_task.phase_progress if display_task else None,
|
||||
@@ -400,9 +386,25 @@ async def _trigger_datasource_batch(
|
||||
datasources: list[DataSource],
|
||||
*,
|
||||
force: bool,
|
||||
actor_id: int | None = None,
|
||||
trigger_kind: str = "batch",
|
||||
) -> dict:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event=f"collector.trigger.{trigger_kind}.start",
|
||||
message="Datasource batch trigger started",
|
||||
category="collector",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=actor_id,
|
||||
context={
|
||||
"trigger_kind": trigger_kind,
|
||||
"force": force,
|
||||
"requested_count": len(datasources),
|
||||
},
|
||||
)
|
||||
if not datasources:
|
||||
return {
|
||||
result = {
|
||||
"status": "noop",
|
||||
"message": "No matching data sources to trigger",
|
||||
"force": force,
|
||||
@@ -410,8 +412,18 @@ async def _trigger_datasource_batch(
|
||||
"skipped": [],
|
||||
"failed": [],
|
||||
}
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event=f"collector.trigger.{trigger_kind}.completed",
|
||||
message="Datasource batch trigger completed with no matching sources",
|
||||
category="collector",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=actor_id,
|
||||
context={"trigger_kind": trigger_kind, "force": force, "status": "noop", "triggered_count": 0},
|
||||
)
|
||||
return result
|
||||
|
||||
previous_task_ids: dict[int, Optional[int]] = {}
|
||||
triggered_sources: list[dict] = []
|
||||
skipped_sources: list[dict] = []
|
||||
failed_sources: list[dict] = []
|
||||
@@ -446,9 +458,11 @@ async def _trigger_datasource_batch(
|
||||
}
|
||||
)
|
||||
continue
|
||||
cancelled = await cancel_running_collector_now(datasource.source)
|
||||
if not cancelled:
|
||||
await rollback_orphaned_running_task(db, datasource, running_task)
|
||||
await request_cancel_datasource_task(
|
||||
db,
|
||||
running_task,
|
||||
reason="superseded_by_forced_collection",
|
||||
)
|
||||
|
||||
if not force and not is_due_for_collection(datasource, now):
|
||||
skipped_sources.append(
|
||||
@@ -465,57 +479,51 @@ async def _trigger_datasource_batch(
|
||||
)
|
||||
continue
|
||||
|
||||
previous_task_ids[datasource.id] = None
|
||||
success = run_collector_now(datasource.source)
|
||||
if not success:
|
||||
failed_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "trigger_failed",
|
||||
}
|
||||
)
|
||||
continue
|
||||
task = await enqueue_datasource_job(
|
||||
db,
|
||||
datasource,
|
||||
JOB_TYPE_COLLECT,
|
||||
payload={"force": force, "trigger": "batch"},
|
||||
)
|
||||
|
||||
triggered_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"task_id": None,
|
||||
"task_id": task.id,
|
||||
}
|
||||
)
|
||||
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
)
|
||||
for datasource_id in previous_task_ids:
|
||||
previous_task_ids[datasource_id] = latest_task_ids.get(datasource_id)
|
||||
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.1)
|
||||
pending = [item for item in triggered_sources if item["task_id"] is None]
|
||||
if not pending:
|
||||
break
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[item["id"] for item in pending],
|
||||
)
|
||||
for item in pending:
|
||||
task_id = latest_task_ids.get(item["id"])
|
||||
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
|
||||
item["task_id"] = task_id
|
||||
|
||||
return {
|
||||
"status": "triggered" if triggered_sources else "partial",
|
||||
"message": f"Triggered {len(triggered_sources)} data sources",
|
||||
result = {
|
||||
"status": "queued" if triggered_sources else "partial",
|
||||
"message": f"Queued {len(triggered_sources)} data source jobs",
|
||||
"force": force,
|
||||
"triggered": triggered_sources,
|
||||
"skipped": skipped_sources,
|
||||
"failed": failed_sources,
|
||||
}
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event=f"collector.trigger.{trigger_kind}.completed",
|
||||
message="Datasource batch trigger completed",
|
||||
category="collector",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=actor_id,
|
||||
context={
|
||||
"trigger_kind": trigger_kind,
|
||||
"force": force,
|
||||
"status": result["status"],
|
||||
"requested_count": len(datasources),
|
||||
"triggered_count": len(triggered_sources),
|
||||
"skipped_count": len(skipped_sources),
|
||||
"failed_count": len(failed_sources),
|
||||
"triggered_sources": [item["source"] for item in triggered_sources],
|
||||
"skipped_reasons": [item["reason"] for item in skipped_sources],
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]:
|
||||
@@ -780,7 +788,13 @@ async def trigger_all_datasources(
|
||||
.order_by(DataSource.module, DataSource.id)
|
||||
)
|
||||
datasources = result.scalars().all()
|
||||
return await _trigger_datasource_batch(db, datasources, force=force)
|
||||
return await _trigger_datasource_batch(
|
||||
db,
|
||||
datasources,
|
||||
force=force,
|
||||
actor_id=current_user.id,
|
||||
trigger_kind="all",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/trigger-batch")
|
||||
@@ -816,7 +830,13 @@ async def trigger_datasource_batch(
|
||||
collected=None if payload.source_ids else payload.collected,
|
||||
credential_status=None if payload.source_ids else payload.credential_status,
|
||||
)
|
||||
return await _trigger_datasource_batch(db, datasources, force=payload.force)
|
||||
return await _trigger_datasource_batch(
|
||||
db,
|
||||
datasources,
|
||||
force=payload.force,
|
||||
actor_id=current_user.id,
|
||||
trigger_kind="batch",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/snapshots")
|
||||
@@ -992,8 +1012,24 @@ async def trigger_datasource(
|
||||
if not datasource.is_active:
|
||||
raise HTTPException(status_code=400, detail="Data source is disabled")
|
||||
|
||||
running_task = await get_running_task(db, datasource.id)
|
||||
running_task = await get_active_datasource_job(db, datasource.id, task_types=(JOB_TYPE_COLLECT,))
|
||||
if running_task is not None and not force:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="collector.trigger.single.skipped_already_running",
|
||||
message="Datasource trigger skipped because a task is already running",
|
||||
category="collector",
|
||||
level="warning",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context={
|
||||
"collector_name": datasource.source,
|
||||
"datasource_id": datasource.id,
|
||||
"task_id": running_task.id,
|
||||
"status": "skipped",
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
@@ -1013,31 +1049,42 @@ async def trigger_datasource(
|
||||
)
|
||||
|
||||
if running_task is not None and force:
|
||||
cancelled = await cancel_running_collector_now(datasource.source)
|
||||
if not cancelled:
|
||||
await rollback_orphaned_running_task(db, datasource, running_task)
|
||||
await request_cancel_datasource_task(
|
||||
db,
|
||||
running_task,
|
||||
reason="superseded_by_forced_collection",
|
||||
)
|
||||
|
||||
previous_task_id = await get_latest_task_id_for_datasource(datasource.id)
|
||||
success = run_collector_now(datasource.source)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to trigger collector '{datasource.source}'")
|
||||
|
||||
task_id = None
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.1)
|
||||
task_id = await get_latest_task_id_for_datasource(datasource.id)
|
||||
if task_id is not None and task_id != previous_task_id:
|
||||
break
|
||||
if task_id == previous_task_id:
|
||||
task_id = None
|
||||
task = await enqueue_datasource_job(
|
||||
db,
|
||||
datasource,
|
||||
JOB_TYPE_COLLECT,
|
||||
payload={"force": force, "trigger": "single"},
|
||||
)
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="collector.trigger.single.completed",
|
||||
message="Datasource trigger queued",
|
||||
category="collector",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context={
|
||||
"collector_name": datasource.source,
|
||||
"datasource_id": datasource.id,
|
||||
"task_id": task.id,
|
||||
"force": force,
|
||||
"status": "queued",
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "triggered",
|
||||
"status": "queued",
|
||||
"source_id": datasource.id,
|
||||
"task_id": task_id,
|
||||
"task_id": task.id,
|
||||
"collector_name": datasource.source,
|
||||
"force": force,
|
||||
"message": f"Collector '{datasource.source}' has been triggered",
|
||||
"message": f"Collector '{datasource.source}' has been queued",
|
||||
}
|
||||
|
||||
|
||||
@@ -1051,22 +1098,30 @@ async def clear_datasource_data(
|
||||
if not datasource:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source)
|
||||
active_task = await get_active_datasource_job(db, datasource.id)
|
||||
if active_task is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"reason": "datasource_job_in_progress",
|
||||
"message": "当前数据源已有任务在执行,请等待完成或先取消任务。",
|
||||
"task_id": active_task.id,
|
||||
"task_type": active_task.task_type,
|
||||
"status": active_task.status,
|
||||
},
|
||||
)
|
||||
task = await enqueue_datasource_job(
|
||||
db,
|
||||
datasource,
|
||||
JOB_TYPE_CLEAR_DATA,
|
||||
payload={"source": datasource.source},
|
||||
)
|
||||
count = result.scalar() or 0
|
||||
|
||||
if count == 0:
|
||||
return {"status": "success", "message": "No data to clear", "deleted_count": 0}
|
||||
|
||||
delete_query = CollectedData.__table__.delete().where(CollectedData.source == datasource.source)
|
||||
await db.execute(delete_query)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Cleared {count} records for data source '{datasource.name}'",
|
||||
"deleted_count": count,
|
||||
"status": "queued",
|
||||
"message": f"Queued data clearing for data source '{datasource.name}'",
|
||||
"task_id": task.id,
|
||||
"deleted_count": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -1080,16 +1135,44 @@ async def clear_datasource_cache(
|
||||
if not datasource:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
|
||||
earth_deleted_count = invalidate_earth_layer_cache_for_source(datasource.source)
|
||||
dashboard_deleted_count = int(cache.delete("dashboard:stats")) + int(cache.delete("dashboard:summary"))
|
||||
deleted_count = earth_deleted_count + dashboard_deleted_count
|
||||
task = await enqueue_datasource_job(
|
||||
db,
|
||||
datasource,
|
||||
JOB_TYPE_CLEAR_CACHE,
|
||||
payload={"source": datasource.source},
|
||||
dedupe_key=f"clear_cache:{datasource.source}",
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Cleared {deleted_count} cache keys for data source '{datasource.name}'",
|
||||
"deleted_count": deleted_count,
|
||||
"earth_layer_deleted_count": earth_deleted_count,
|
||||
"dashboard_deleted_count": dashboard_deleted_count,
|
||||
"status": "queued",
|
||||
"message": f"Queued cache clearing for data source '{datasource.name}'",
|
||||
"task_id": task.id,
|
||||
"deleted_count": None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{source_id}/tasks/{task_id}/cancel")
|
||||
async def cancel_datasource_task(
|
||||
source_id: str,
|
||||
task_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
datasource = await get_datasource_record(db, source_id)
|
||||
if not datasource:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
|
||||
task = await db.get(CollectionTask, task_id)
|
||||
if not task or task.datasource_id != datasource.id:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
task = await request_cancel_datasource_task(db, task)
|
||||
return {
|
||||
"status": "cancelled" if task.completed_at else "cancelling",
|
||||
"task_id": task.id,
|
||||
"task_type": task.task_type,
|
||||
"phase": task.phase,
|
||||
"requested_cancel_at": to_iso8601_utc(task.requested_cancel_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -1109,7 +1192,7 @@ async def get_task_status(
|
||||
if not task or task.datasource_id != datasource.id:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
else:
|
||||
task = await get_running_task(db, datasource.id)
|
||||
task = await get_active_datasource_job(db, datasource.id)
|
||||
if task is None:
|
||||
result = await db.execute(
|
||||
select(CollectionTask)
|
||||
@@ -1134,8 +1217,12 @@ async def get_task_status(
|
||||
}
|
||||
|
||||
return {
|
||||
"is_running": task.status == "running",
|
||||
"is_running": task.status in {JOB_STATUS_QUEUED, JOB_STATUS_RUNNING, JOB_STATUS_CANCELLING}
|
||||
and task.task_type == JOB_TYPE_COLLECT,
|
||||
"is_task_active": task.status in {JOB_STATUS_QUEUED, JOB_STATUS_RUNNING, JOB_STATUS_CANCELLING},
|
||||
"task_id": task.id,
|
||||
"task_type": task.task_type,
|
||||
"task_status": task.status,
|
||||
"progress": task.progress,
|
||||
"phase": task.phase,
|
||||
"phase_progress": task.phase_progress,
|
||||
@@ -1146,5 +1233,6 @@ async def get_task_status(
|
||||
"records_processed": task.records_processed,
|
||||
"total_records": task.total_records,
|
||||
"status": task.status,
|
||||
"requested_cancel_at": to_iso8601_utc(task.requested_cancel_at),
|
||||
"error_message": task.error_message,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings as app_settings
|
||||
from app.core.security import decode_token, get_current_user, redis_client
|
||||
from app.db.session import get_db
|
||||
from app.models.collected_data import CollectedData
|
||||
@@ -36,9 +37,16 @@ EARTH_BRAND_ASSET_DIR = REPO_ROOT / "data" / "earth-brand"
|
||||
EARTH_BRAND_ASSET_URL_PREFIX = "/earth-brand-assets"
|
||||
EARTH_BRAND_CATEGORY = "earth_brand"
|
||||
EARTH_ABOUT_CATEGORY = "earth_about"
|
||||
SYSTEM_SETTINGS_CATEGORY = "system"
|
||||
MAX_EARTH_BRAND_ASSET_BYTES = 3 * 1024 * 1024
|
||||
ALLOWED_EARTH_BRAND_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
|
||||
|
||||
|
||||
def _app_version_label() -> str:
|
||||
version = str(app_settings.VERSION or "").strip() or "0.0.0"
|
||||
return version if version.startswith("v") else f"v{version}"
|
||||
|
||||
|
||||
DEFAULT_EARTH_BRAND = {
|
||||
"logo_src": "/earth/assets/brand/earth-logo.png",
|
||||
"title_src": "/earth/assets/brand/title-zh.png",
|
||||
@@ -53,14 +61,15 @@ DEFAULT_EARTH_ABOUT = {
|
||||
"logo_src": "/earth/assets/brand/lim-logo.png",
|
||||
"kicker": "About",
|
||||
"title": "智能星球计划",
|
||||
"version": "v0.64.0",
|
||||
"version": _app_version_label(),
|
||||
"description": "面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。",
|
||||
"meta": [
|
||||
{"label": "出品方", "value": "浙江大学临空智能媒体研究院"},
|
||||
{"label": "策划人", "value": "黄柳青"},
|
||||
{"label": "策划人", "value": "方兴东、黄柳青"},
|
||||
{"label": "产品兼开发者", "value": "钱坤、张鸽、齐鹏"},
|
||||
],
|
||||
}
|
||||
EARTH_ABOUT_LEGACY_PLANNER_VALUE = "黄柳青"
|
||||
|
||||
|
||||
class EarthBoundaryConfigPayload(BaseModel):
|
||||
@@ -116,11 +125,12 @@ def _normalize_earth_about_payload(payload: dict[str, Any] | None) -> dict[str,
|
||||
}
|
||||
raw_meta = DEFAULT_EARTH_ABOUT["meta"]
|
||||
if payload:
|
||||
for key in ("logo_src", "kicker", "title", "version", "description"):
|
||||
for key in ("logo_src", "kicker", "title", "description"):
|
||||
value = payload.get(key)
|
||||
if value is not None:
|
||||
merged[key] = str(value).strip()
|
||||
raw_meta = payload.get("meta") if isinstance(payload.get("meta"), list) else raw_meta
|
||||
merged["version"] = _app_version_label()
|
||||
|
||||
for key, default_value in DEFAULT_EARTH_ABOUT.items():
|
||||
if key == "meta":
|
||||
@@ -134,6 +144,8 @@ def _normalize_earth_about_payload(payload: dict[str, Any] | None) -> dict[str,
|
||||
continue
|
||||
label = str(item.get("label") or "").strip()
|
||||
value = str(item.get("value") or "").strip()
|
||||
if label == "策划人" and value == EARTH_ABOUT_LEGACY_PLANNER_VALUE:
|
||||
value = "方兴东、黄柳青"
|
||||
if label or value:
|
||||
normalized_meta.append({"label": label, "value": value})
|
||||
if not normalized_meta:
|
||||
@@ -142,6 +154,10 @@ def _normalize_earth_about_payload(payload: dict[str, Any] | None) -> dict[str,
|
||||
return merged
|
||||
|
||||
|
||||
def _is_demo_mode_enabled(payload: Any) -> bool:
|
||||
return bool(payload.get("demo_mode")) if isinstance(payload, dict) else False
|
||||
|
||||
|
||||
async def _get_earth_brand_record(db: AsyncSession) -> SystemSetting | None:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.category == EARTH_BRAND_CATEGORY)
|
||||
@@ -317,6 +333,11 @@ async def get_earth_oobe_status(
|
||||
select(func.count(CollectedData.id)).where(CollectedData.is_current.is_(True))
|
||||
)
|
||||
current_record_count = int(current_count_result.scalar() or 0)
|
||||
system_result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.category == SYSTEM_SETTINGS_CATEGORY)
|
||||
)
|
||||
system_record = system_result.scalar_one_or_none()
|
||||
demo_mode = _is_demo_mode_enabled(system_record.payload if system_record else None)
|
||||
|
||||
datasource_count_result = await db.execute(select(func.count(DataSource.id)))
|
||||
datasource_count = int(datasource_count_result.scalar() or 0)
|
||||
@@ -337,6 +358,8 @@ async def get_earth_oobe_status(
|
||||
ready = has_collected_data
|
||||
|
||||
suggestions: list[str] = []
|
||||
if demo_mode:
|
||||
suggestions.append("演示模式已开启")
|
||||
if not current_user:
|
||||
suggestions.append("登录控制台")
|
||||
if not has_collected_data:
|
||||
@@ -348,8 +371,9 @@ async def get_earth_oobe_status(
|
||||
|
||||
return {
|
||||
"ready": ready,
|
||||
"demo_mode": demo_mode,
|
||||
"authenticated": current_user is not None,
|
||||
"needs_login": current_user is None and not ready,
|
||||
"needs_login": current_user is None and not ready and not demo_mode,
|
||||
"has_collected_data": has_collected_data,
|
||||
"has_tv_sources": tv_source_count > 0,
|
||||
"has_core_layers": has_core_layers,
|
||||
|
||||
190
backend/app/api/v1/interactables.py
Normal file
190
backend/app/api/v1/interactables.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""CRUD APIs for persistent Earth interactables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.earth_interactable import EarthInteractable
|
||||
from app.models.user import User
|
||||
from app.services.earth_interactables import (
|
||||
build_interactable_event,
|
||||
interactables_to_geojson,
|
||||
invalidate_interactable_cache,
|
||||
list_interactables,
|
||||
normalize_interactable_id,
|
||||
publish_interactable_event,
|
||||
serialize_interactable,
|
||||
)
|
||||
from app.services.earth_layer_cache import (
|
||||
EarthLayerCachePolicy,
|
||||
earth_layer_cache,
|
||||
get_or_build_layer_payload,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
INTERACTABLE_CACHE_POLICY = EarthLayerCachePolicy(
|
||||
fresh_ttl_seconds=60,
|
||||
stale_ttl_seconds=10 * 60,
|
||||
max_features=5000,
|
||||
)
|
||||
|
||||
|
||||
class InteractableCreate(BaseModel):
|
||||
id: str | None = Field(default=None, max_length=160)
|
||||
layer: str = Field(default="default", min_length=1, max_length=80)
|
||||
kind: str = Field(default="default", min_length=1, max_length=80)
|
||||
label: str = Field(default="", max_length=255)
|
||||
description: str = Field(default="", max_length=4000)
|
||||
latitude: float = Field(ge=-90, le=90)
|
||||
longitude: float = Field(ge=-180, le=180)
|
||||
altitude: float | None = None
|
||||
properties: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("layer", "kind")
|
||||
@classmethod
|
||||
def normalize_key(cls, value: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("must not be empty")
|
||||
return normalized
|
||||
|
||||
|
||||
class InteractableUpdate(BaseModel):
|
||||
layer: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
kind: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
label: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=4000)
|
||||
latitude: float | None = Field(default=None, ge=-90, le=90)
|
||||
longitude: float | None = Field(default=None, ge=-180, le=180)
|
||||
altitude: float | None = None
|
||||
properties: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_interactables(
|
||||
response: Response,
|
||||
layer: str | None = Query(default=None),
|
||||
include_deleted: bool = Query(default=False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
items = await list_interactables(db, layer=layer, include_deleted=include_deleted)
|
||||
response.headers["X-Planet-Interactables-Count"] = str(len(items))
|
||||
return {"items": [serialize_interactable(item) for item in items]}
|
||||
|
||||
|
||||
@router.get("/geojson")
|
||||
async def get_interactables_geojson(
|
||||
response: Response,
|
||||
layer: str | None = Query(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
async def build_payload() -> dict[str, Any]:
|
||||
items = await list_interactables(db, layer=layer)
|
||||
return interactables_to_geojson(items)
|
||||
|
||||
payload = await get_or_build_layer_payload(
|
||||
key=earth_layer_cache.key("interactables", layer=layer or "all"),
|
||||
policy=INTERACTABLE_CACHE_POLICY,
|
||||
builder=build_payload,
|
||||
response=response,
|
||||
)
|
||||
response.headers["X-Planet-Interactables-Count"] = str(len(payload.get("features") or []))
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_interactable(
|
||||
payload: InteractableCreate,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
record_id = normalize_interactable_id(payload.id)
|
||||
existing = await db.get(EarthInteractable, record_id)
|
||||
if existing and not existing.is_deleted:
|
||||
raise HTTPException(status_code=409, detail="Interactable already exists")
|
||||
|
||||
if existing is None:
|
||||
record = EarthInteractable(id=record_id)
|
||||
db.add(record)
|
||||
else:
|
||||
record = existing
|
||||
record.is_deleted = False
|
||||
record.deleted_at = None
|
||||
record.revision += 1
|
||||
|
||||
record.layer = payload.layer
|
||||
record.kind = payload.kind
|
||||
record.label = payload.label
|
||||
record.description = payload.description
|
||||
record.latitude = payload.latitude
|
||||
record.longitude = payload.longitude
|
||||
record.altitude = payload.altitude
|
||||
record.properties = payload.properties
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
invalidate_interactable_cache(record.layer)
|
||||
await publish_interactable_event("created", record)
|
||||
return {"item": serialize_interactable(record)}
|
||||
|
||||
|
||||
@router.get("/{interactable_id}")
|
||||
async def get_interactable(interactable_id: str, db: AsyncSession = Depends(get_db)):
|
||||
record = await db.get(EarthInteractable, interactable_id)
|
||||
if record is None or record.is_deleted:
|
||||
raise HTTPException(status_code=404, detail="Interactable not found")
|
||||
return {"item": serialize_interactable(record)}
|
||||
|
||||
|
||||
@router.patch("/{interactable_id}")
|
||||
async def update_interactable(
|
||||
interactable_id: str,
|
||||
payload: InteractableUpdate,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
record = await db.get(EarthInteractable, interactable_id)
|
||||
if record is None or record.is_deleted:
|
||||
raise HTTPException(status_code=404, detail="Interactable not found")
|
||||
|
||||
previous_layer = record.layer
|
||||
patch = payload.model_dump(exclude_unset=True)
|
||||
for key, value in patch.items():
|
||||
setattr(record, key, value)
|
||||
record.revision += 1
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
invalidate_interactable_cache(previous_layer)
|
||||
if record.layer != previous_layer:
|
||||
invalidate_interactable_cache(record.layer)
|
||||
await publish_interactable_event("updated", record)
|
||||
return {"item": serialize_interactable(record)}
|
||||
|
||||
|
||||
@router.delete("/{interactable_id}")
|
||||
async def delete_interactable(
|
||||
interactable_id: str,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
record = await db.get(EarthInteractable, interactable_id)
|
||||
if record is None or record.is_deleted:
|
||||
raise HTTPException(status_code=404, detail="Interactable not found")
|
||||
|
||||
record.is_deleted = True
|
||||
record.deleted_at = datetime.now(UTC)
|
||||
record.revision += 1
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
invalidate_interactable_cache(record.layer)
|
||||
await publish_interactable_event("deleted", record)
|
||||
event = build_interactable_event(action="deleted", record=record, include_item=True)
|
||||
return {"deleted": True, "event": event}
|
||||
@@ -11,6 +11,7 @@ from dotenv import dotenv_values
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.core.security import get_current_user
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.config import settings as app_settings
|
||||
@@ -66,8 +67,10 @@ from app.services.llm_provider_catalog import (
|
||||
from app.services.scheduler import sync_datasource_job
|
||||
from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload, normalize_tv_settings
|
||||
from app.services.persistent_logs import record_audit_log
|
||||
from app.services.business_logs import emit_business_log, exception_context
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__, service="api")
|
||||
AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS = 5
|
||||
AI_CONNECTION_TEST_PROMPT_KEY = "ai.connection_test"
|
||||
SECRET_REVEAL_ROLES = {"admin", "super_admin"}
|
||||
@@ -79,6 +82,7 @@ DEFAULT_SETTINGS = {
|
||||
"auto_refresh": True,
|
||||
"data_retention_days": 30,
|
||||
"max_concurrent_tasks": 5,
|
||||
"demo_mode": False,
|
||||
},
|
||||
"notifications": {
|
||||
"email_enabled": False,
|
||||
@@ -203,6 +207,7 @@ class SystemSettingsUpdate(BaseModel):
|
||||
auto_refresh: bool = True
|
||||
data_retention_days: int = Field(default=30, ge=1, le=3650)
|
||||
max_concurrent_tasks: int = Field(default=5, ge=1, le=50)
|
||||
demo_mode: bool = False
|
||||
|
||||
|
||||
class NotificationSettingsUpdate(BaseModel):
|
||||
@@ -699,6 +704,18 @@ async def _validate_ai_provider_full_connection(ai_payload: dict) -> dict:
|
||||
retry_attempts=runtime_config["retry_attempts"],
|
||||
llm_config=runtime_config.get("llm_config") or {},
|
||||
)
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.ai_provider.full_connection.start",
|
||||
message="AI provider full connection validation started",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
context={
|
||||
"provider": runtime_config.get("llm_config", {}).get("provider"),
|
||||
"model": runtime_config.get("llm_config", {}).get("model"),
|
||||
},
|
||||
)
|
||||
status_result = await client.get_status()
|
||||
if not status_result.configured:
|
||||
raise HTTPException(
|
||||
@@ -715,6 +732,19 @@ async def _validate_ai_provider_full_connection(ai_payload: dict) -> dict:
|
||||
constraints=["回复尽量简短。"],
|
||||
)
|
||||
)
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.ai_provider.full_connection.success",
|
||||
message="AI provider full connection validation completed",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
context={
|
||||
"provider": analysis_result.provider,
|
||||
"model": analysis_result.model,
|
||||
"configured": status_result.configured,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"status": status_result.model_dump(),
|
||||
"provider": analysis_result.provider,
|
||||
@@ -1612,9 +1642,34 @@ async def connect_ai_provider_integration(
|
||||
llm_config=quick_llm_config,
|
||||
)
|
||||
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.ai_provider.connect.start",
|
||||
message="AI provider connection test started",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context={
|
||||
"provider": payload.provider,
|
||||
"model": payload.model,
|
||||
"timeout_seconds": min(int(runtime_config["timeout_seconds"] or 60), AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS),
|
||||
},
|
||||
)
|
||||
try:
|
||||
status_result = await client.get_status()
|
||||
if not status_result.configured:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.ai_provider.connect.failed",
|
||||
message="AI provider connection test failed because provider is incomplete",
|
||||
category="ai",
|
||||
level="warning",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context={"provider": payload.provider, "model": payload.model, "configured": False},
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"connected": False,
|
||||
@@ -1628,6 +1683,21 @@ async def connect_ai_provider_integration(
|
||||
AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.ai_provider.connect.success",
|
||||
message="AI provider connection test completed",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context={
|
||||
"provider": payload.provider,
|
||||
"model": payload.model,
|
||||
"configured": True,
|
||||
"lightweight_status": lightweight_result.get("status"),
|
||||
},
|
||||
)
|
||||
return {
|
||||
**lightweight_result,
|
||||
"status": status_result.model_dump(),
|
||||
@@ -1639,6 +1709,17 @@ async def connect_ai_provider_integration(
|
||||
"message": str(exc.detail),
|
||||
}
|
||||
except Exception as exc:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.ai_provider.connect.failed",
|
||||
message="AI provider connection test failed",
|
||||
category="ai",
|
||||
level="error",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context=exception_context(exc, {"provider": payload.provider, "model": payload.model}),
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"connected": False,
|
||||
@@ -1786,8 +1867,28 @@ async def connect_web_search_integration(
|
||||
runtime_config = _runtime_config_from_web_search_payload(draft_web_search_payload)
|
||||
client = WebSearchClient(runtime_config)
|
||||
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.web_search.connect.start",
|
||||
message="WebSearch connection test started",
|
||||
category="ai_tool",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context={"provider": runtime_config.default_provider},
|
||||
)
|
||||
try:
|
||||
results = await client.test_connection()
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.web_search.connect.success",
|
||||
message="WebSearch connection test completed",
|
||||
category="ai_tool",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context={"provider": runtime_config.default_provider, "result_count": len(results)},
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"connected": True,
|
||||
@@ -1796,18 +1897,51 @@ async def connect_web_search_integration(
|
||||
"results": [item.model_dump(mode="json") for item in results[:3]],
|
||||
}
|
||||
except WebSearchConfigurationError as exc:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.web_search.connect.failed",
|
||||
message="WebSearch connection test failed because configuration is incomplete",
|
||||
category="ai_tool",
|
||||
level="warning",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context=exception_context(exc, {"provider": runtime_config.default_provider}),
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"connected": False,
|
||||
"message": str(exc),
|
||||
}
|
||||
except WebSearchError as exc:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.web_search.connect.failed",
|
||||
message="WebSearch connection test failed",
|
||||
category="ai_tool",
|
||||
level="error",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context=exception_context(exc, {"provider": runtime_config.default_provider}),
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"connected": False,
|
||||
"message": str(exc),
|
||||
}
|
||||
except Exception as exc:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.web_search.connect.failed",
|
||||
message="WebSearch connection test failed",
|
||||
category="ai_tool",
|
||||
level="error",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context=exception_context(exc, {"provider": runtime_config.default_provider}),
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"connected": False,
|
||||
@@ -1835,17 +1969,60 @@ async def generate_provider_credential_guide(
|
||||
ai_client: AIProviderClient = Depends(get_ai_provider_client),
|
||||
):
|
||||
try:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.credential_guide.generate.start",
|
||||
message="Credential guide generation started",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context={"provider": provider},
|
||||
)
|
||||
web_search_client = await get_web_search_client(db)
|
||||
return {
|
||||
"guide": await generate_credential_guide(
|
||||
guide = await generate_credential_guide(
|
||||
db,
|
||||
provider,
|
||||
ai_client,
|
||||
web_search_client,
|
||||
)
|
||||
}
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.credential_guide.generate.success",
|
||||
message="Credential guide generation completed",
|
||||
category="ai",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context={"provider": provider},
|
||||
)
|
||||
return {"guide": guide}
|
||||
except ValueError as exc:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.credential_guide.generate.failed",
|
||||
message="Credential guide generation failed",
|
||||
category="ai",
|
||||
level="warning",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context=exception_context(exc, {"provider": provider}),
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.credential_guide.generate.failed",
|
||||
message="Credential guide generation failed",
|
||||
category="ai",
|
||||
level="error",
|
||||
service="api",
|
||||
module=__name__,
|
||||
user_id=current_user.id,
|
||||
context=exception_context(exc, {"provider": provider}),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/credential-guides/{provider}/reset")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@@ -44,6 +45,42 @@ from app.services.earth_layer_cache import earth_layer_cache
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _compact_log_context(context: dict | None) -> str:
|
||||
if not context:
|
||||
return ""
|
||||
allowed = {
|
||||
key: value
|
||||
for key, value in (context or {}).items()
|
||||
if key
|
||||
in {
|
||||
"status",
|
||||
"duration_ms",
|
||||
"provider",
|
||||
"model",
|
||||
"result_provider",
|
||||
"result_model",
|
||||
"collector_name",
|
||||
"datasource_id",
|
||||
"task_id",
|
||||
"snapshot_id",
|
||||
"raw_count",
|
||||
"transformed_count",
|
||||
"saved_count",
|
||||
"created",
|
||||
"updated",
|
||||
"unchanged",
|
||||
"deleted",
|
||||
"result_count",
|
||||
"status_code",
|
||||
"error_type",
|
||||
"error",
|
||||
}
|
||||
}
|
||||
if not allowed:
|
||||
return ""
|
||||
return json.dumps(allowed, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
class RestartTaskCreate(BaseModel):
|
||||
action: str
|
||||
|
||||
@@ -374,8 +411,11 @@ async def read_database_log_snapshot(
|
||||
record.occurred_at.isoformat() if record.occurred_at else "",
|
||||
record_level.upper(),
|
||||
record.source,
|
||||
record.category or "",
|
||||
record.event or "",
|
||||
f"request_id={record.request_id}" if record.request_id else "",
|
||||
record.message,
|
||||
_compact_log_context(record.context),
|
||||
]
|
||||
if part
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ from app.services.compute_center_locations import (
|
||||
upsert_compute_center_location,
|
||||
)
|
||||
from app.services.ai_client import get_ai_provider_client
|
||||
from app.api.v1.settings import get_web_search_client
|
||||
from app.api.v1.settings import get_runtime_web_search_config, get_web_search_client
|
||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||
from app.services.location.llm_fallback import (
|
||||
collect_llm_location_fallback_candidate,
|
||||
@@ -105,8 +105,8 @@ LANDING_POINT_CACHE_POLICY = EarthLayerCachePolicy(
|
||||
SATELLITE_CACHE_POLICY = EarthLayerCachePolicy(
|
||||
SATELLITE_CACHE_FRESH_SECONDS,
|
||||
SATELLITE_CACHE_STALE_SECONDS,
|
||||
max_features=8000,
|
||||
max_bytes=10 * BYTES_PER_MIB,
|
||||
max_features=25000,
|
||||
max_bytes=32 * BYTES_PER_MIB,
|
||||
)
|
||||
COMPUTE_CENTER_CACHE_POLICY = EarthLayerCachePolicy(
|
||||
COMPUTE_CENTER_CACHE_FRESH_SECONDS,
|
||||
@@ -1563,15 +1563,7 @@ async def _build_cables_geojson(db: AsyncSession) -> dict[str, Any]:
|
||||
try:
|
||||
records = await _load_current_collected_data(db, "arcgis_cables")
|
||||
|
||||
if not records:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No cable data found. Please run the arcgis_cables collector first.",
|
||||
)
|
||||
|
||||
return convert_cable_to_geojson(records)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception_event(
|
||||
"Failed to build cables GeoJSON response",
|
||||
@@ -1625,16 +1617,8 @@ async def _build_landing_points_geojson(db: AsyncSession) -> dict[str, Any]:
|
||||
relation_records,
|
||||
cable_records,
|
||||
)
|
||||
|
||||
if not records:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No landing point data found. Please run the arcgis_landing_points collector first.",
|
||||
)
|
||||
|
||||
|
||||
return convert_landing_point_to_geojson(records, city_to_cable_ids_map, cable_id_to_name_map)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception_event(
|
||||
"Failed to build landing points GeoJSON response",
|
||||
@@ -2071,6 +2055,44 @@ class SaveComputeCenterLocationRequest(BaseModel):
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
async def _compute_center_location_web_search_capability(db: AsyncSession) -> Dict[str, Any]:
|
||||
try:
|
||||
config = await get_runtime_web_search_config(db)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"enabled": False,
|
||||
"provider": None,
|
||||
"reason": f"WebSearch 配置读取失败:{exc}",
|
||||
}
|
||||
provider_config = config.active_provider_config
|
||||
has_api_key = bool((provider_config.api_key or "").strip())
|
||||
if not config.enabled:
|
||||
return {
|
||||
"enabled": False,
|
||||
"provider": config.default_provider,
|
||||
"reason": "WebSearch 未开启,无法进行事实核查定位。",
|
||||
}
|
||||
if not has_api_key:
|
||||
return {
|
||||
"enabled": False,
|
||||
"provider": config.default_provider,
|
||||
"reason": f"WebSearch Provider {config.default_provider} 未配置 API Key。",
|
||||
}
|
||||
return {
|
||||
"enabled": True,
|
||||
"provider": config.default_provider,
|
||||
"reason": "",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/compute-centers/location-capability")
|
||||
async def get_compute_center_location_capability(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return whether fact-checked compute-center location collection can run."""
|
||||
return await _compute_center_location_web_search_capability(db)
|
||||
|
||||
|
||||
@router.post("/compute-centers/{source_id}/collect-location")
|
||||
async def collect_compute_center_location(
|
||||
source_id: str,
|
||||
@@ -2089,6 +2111,9 @@ async def collect_compute_center_location(
|
||||
"""
|
||||
if not source_id or not source_id.strip():
|
||||
raise HTTPException(status_code=400, detail="source_id is required")
|
||||
capability = await _compute_center_location_web_search_capability(db)
|
||||
if not capability.get("enabled"):
|
||||
raise HTTPException(status_code=409, detail=capability)
|
||||
|
||||
record = await _load_compute_center_record(db, source_id)
|
||||
name = payload.name or (record.name if record else None)
|
||||
@@ -2696,6 +2721,8 @@ async def _build_bgp_collectors_geojson(db: AsyncSession) -> dict[str, Any]:
|
||||
db,
|
||||
source_filter=("ris_live_bgp", "bgpstream_bgp"),
|
||||
)
|
||||
if not any(int(item.get("observation_count") or 0) > 0 for item in coverage):
|
||||
return {"type": "FeatureCollection", "features": [], "count": 0}
|
||||
coverage_by_collector = {
|
||||
item["collector"]: item
|
||||
for item in coverage
|
||||
|
||||
Reference in New Issue
Block a user