v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
"""Qualify router: decision qualification engine endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.engine.qualifier import QualificationConfig, QualificationInput, qualify
|
||||
from app.models.qualification import QualificationProtocol
|
||||
from app.schemas.qualification import (
|
||||
QualificationProtocolCreate,
|
||||
QualificationProtocolOut,
|
||||
QualifyRequest,
|
||||
QualifyResponse,
|
||||
)
|
||||
from app.services.auth_service import get_current_identity
|
||||
from app.services.qualify_ai_service import (
|
||||
AIFrameRequest,
|
||||
AIFrameResponse,
|
||||
AIMessage,
|
||||
AIQuestion,
|
||||
AIQualifyResult,
|
||||
ai_frame,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Pydantic wrappers for AI chat (FastAPI needs Pydantic, not dataclasses) ──
|
||||
|
||||
|
||||
class AIMessagePayload(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class AIChatRequest(BaseModel):
|
||||
within_mandate: bool = False
|
||||
affected_count: int | None = None
|
||||
is_structural: bool = False
|
||||
context: str | None = None
|
||||
messages: list[AIMessagePayload] = []
|
||||
|
||||
|
||||
class AIQuestionOut(BaseModel):
|
||||
id: str
|
||||
text: str
|
||||
options: list[str]
|
||||
|
||||
|
||||
class AIQualifyResultOut(BaseModel):
|
||||
decision_type: str
|
||||
process: str
|
||||
recommended_modalities: list[str]
|
||||
recommend_onchain: bool
|
||||
onchain_reason: str | None
|
||||
confidence: str
|
||||
collective_available: bool
|
||||
record_in_observatory: bool
|
||||
reasons: list[str]
|
||||
|
||||
|
||||
class AIChatResponse(BaseModel):
|
||||
done: bool
|
||||
questions: list[AIQuestionOut] = []
|
||||
result: AIQualifyResultOut | None = None
|
||||
explanation: str | None = None
|
||||
|
||||
|
||||
async def _load_config(db: AsyncSession) -> QualificationConfig:
|
||||
"""Load the active QualificationProtocol from DB, or fall back to defaults."""
|
||||
result = await db.execute(
|
||||
select(QualificationProtocol)
|
||||
.where(QualificationProtocol.is_active == True) # noqa: E712
|
||||
.order_by(QualificationProtocol.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
proto = result.scalar_one_or_none()
|
||||
if proto is None:
|
||||
return QualificationConfig()
|
||||
return QualificationConfig(
|
||||
small_group_max=proto.small_group_max,
|
||||
collective_wot_min=proto.collective_wot_min,
|
||||
default_modalities=proto.default_modalities,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=QualifyResponse)
|
||||
async def qualify_decision(
|
||||
payload: QualifyRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> QualifyResponse:
|
||||
"""Qualify a decision: determine type, process, and modalities.
|
||||
|
||||
No authentication required — this is an advisory endpoint that helps
|
||||
users understand which decision pathway fits their situation.
|
||||
"""
|
||||
config = await _load_config(db)
|
||||
inp = QualificationInput(
|
||||
within_mandate=payload.within_mandate,
|
||||
affected_count=payload.affected_count,
|
||||
is_structural=payload.is_structural,
|
||||
context_description=payload.context_description,
|
||||
)
|
||||
result = qualify(inp, config)
|
||||
return QualifyResponse(**asdict(result))
|
||||
|
||||
|
||||
@router.post("/ai-chat", response_model=AIChatResponse)
|
||||
async def ai_chat(payload: AIChatRequest) -> AIChatResponse:
|
||||
"""Run one round of AI-assisted qualification framing.
|
||||
|
||||
Round 1 (messages=[]) → returns 2 clarifying questions.
|
||||
Round 2 (messages set) → returns final qualification result.
|
||||
|
||||
No auth required — advisory endpoint.
|
||||
"""
|
||||
req = AIFrameRequest(
|
||||
within_mandate=payload.within_mandate,
|
||||
affected_count=payload.affected_count,
|
||||
is_structural=payload.is_structural,
|
||||
context=payload.context,
|
||||
messages=[AIMessage(role=m.role, content=m.content) for m in payload.messages],
|
||||
)
|
||||
resp = ai_frame(req)
|
||||
|
||||
return AIChatResponse(
|
||||
done=resp.done,
|
||||
questions=[AIQuestionOut(id=q.id, text=q.text, options=q.options) for q in resp.questions],
|
||||
result=AIQualifyResultOut(**asdict(resp.result)) if resp.result else None,
|
||||
explanation=resp.explanation,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/protocol", response_model=QualificationProtocolOut | None)
|
||||
async def get_active_protocol(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> QualificationProtocolOut | None:
|
||||
"""Return the currently active qualification protocol (thresholds)."""
|
||||
result = await db.execute(
|
||||
select(QualificationProtocol)
|
||||
.where(QualificationProtocol.is_active == True) # noqa: E712
|
||||
.order_by(QualificationProtocol.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
proto = result.scalar_one_or_none()
|
||||
if proto is None:
|
||||
return None
|
||||
return QualificationProtocolOut.model_validate(proto)
|
||||
|
||||
|
||||
@router.post("/protocol", response_model=QualificationProtocolOut, status_code=201)
|
||||
async def create_protocol(
|
||||
payload: QualificationProtocolCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_identity=Depends(get_current_identity),
|
||||
) -> QualificationProtocolOut:
|
||||
"""Create a new qualification protocol (requires auth).
|
||||
|
||||
Deactivates the current active protocol before saving the new one.
|
||||
"""
|
||||
# Deactivate current
|
||||
current = await db.execute(
|
||||
select(QualificationProtocol).where(QualificationProtocol.is_active == True) # noqa: E712
|
||||
)
|
||||
for proto in current.scalars().all():
|
||||
proto.is_active = False
|
||||
|
||||
import json
|
||||
proto = QualificationProtocol(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
small_group_max=payload.small_group_max,
|
||||
collective_wot_min=payload.collective_wot_min,
|
||||
default_modalities_json=json.dumps(payload.default_modalities),
|
||||
is_active=True,
|
||||
)
|
||||
db.add(proto)
|
||||
await db.commit()
|
||||
await db.refresh(proto)
|
||||
return QualificationProtocolOut.model_validate(proto)
|
||||
Reference in New Issue
Block a user