CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
159 lines
6.3 KiB
Python
159 lines
6.3 KiB
Python
"""Pure-Python DTOs exchanged with :mod:`routing_application_service`.
|
|
|
|
These types are the vocabulary the chat surfaces (Cowork chat, Co4E, AI-Edit)
|
|
now speak instead of each re-deriving routing state from raw config lookups and
|
|
``core/routing`` internals.
|
|
|
|
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application
|
|
code is 100% pure Python. Nothing here imports PySide6, and nothing here imports
|
|
``core.routing`` either — the concrete routing engine is reached only through
|
|
the adapter in :mod:`core_routing_adapter`, which keeps this module trivially
|
|
testable with plain fakes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Any, Optional, Tuple
|
|
|
|
|
|
class RoutingMode(str, Enum):
|
|
"""The four routing behaviours a surface can be in (R03-T03).
|
|
|
|
``OFF``/``AUTO``/``MANUAL`` map 1:1 onto the existing per-surface toggle and
|
|
onto ``core/routing/models.py::SwitchMode``. ``FALLBACK`` is new and
|
|
deliberately NOT an optimisation mode: it keeps whatever model the user
|
|
chose and only re-routes when that model cannot serve the turn, which is the
|
|
behaviour a resilience-minded workspace wants (never surprise me, but never
|
|
leave me stuck either).
|
|
"""
|
|
|
|
OFF = "off"
|
|
AUTO = "auto"
|
|
MANUAL = "manual"
|
|
FALLBACK = "fallback"
|
|
|
|
@classmethod
|
|
def parse(cls, raw: Any, default: "RoutingMode" = None) -> "RoutingMode":
|
|
"""Best-effort coercion from config/UI strings.
|
|
|
|
Routing must never break a turn, so an unrecognised value degrades to
|
|
``default`` (``OFF`` unless told otherwise) instead of raising — the same
|
|
defensive posture ``config.routing_mode_for`` already takes.
|
|
"""
|
|
fallback = default if default is not None else cls.OFF
|
|
if isinstance(raw, cls):
|
|
return raw
|
|
try:
|
|
return cls(str(raw or "").strip().lower())
|
|
except ValueError:
|
|
return fallback
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RoutingRequest:
|
|
"""Everything needed to decide how ONE turn should be routed.
|
|
|
|
Frozen: the request is captured from live UI state (the selected model, the
|
|
typed prompt) and then handed to code that may run on a worker thread. An
|
|
immutable snapshot means the user changing the model picker mid-turn cannot
|
|
retroactively alter the decision that was already made — the same rationale
|
|
behind R04's ``ConversationExecutionRequest``.
|
|
"""
|
|
|
|
surface: str # "cowork" | "co4e" | "ai_edit" | ...
|
|
prompt: str # the user's text; drives task classification
|
|
current_provider: str # provider the surface would use as-is
|
|
current_model: str = "" # model the surface would use ("" = provider default)
|
|
mode: Optional[RoutingMode] = None # explicit override; None -> resolve per surface
|
|
# Pre-classified task type ("coding", "qa", ...). AI-Edit always knows its
|
|
# turns are coding work, so it pins this and skips prompt classification.
|
|
task_type: Optional[str] = None
|
|
required_capabilities: Tuple[str, ...] = () # e.g. ("vision",)
|
|
|
|
@property
|
|
def has_prompt(self) -> bool:
|
|
"""Whether there is anything to classify. An empty prompt cannot be
|
|
routed meaningfully, so every surface short-circuits on it."""
|
|
return bool((self.prompt or "").strip())
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RouteEvaluation:
|
|
"""A routing engine's verdict, normalised away from ``core/routing`` types.
|
|
|
|
The adapter flattens ``RouteResult``/``SwitchDecision`` into these plain
|
|
fields so the application service never touches Pydantic models or enums
|
|
owned by another layer. ``decision`` still carries the original object
|
|
because the Manual-mode confirm dialog renders its ``reason``.
|
|
"""
|
|
|
|
task_type: str
|
|
should_switch: bool
|
|
target_provider: Optional[str] = None
|
|
target_model: Optional[str] = None
|
|
score_gain: float = 0.0
|
|
reason: str = ""
|
|
# False when the currently selected model is not a usable candidate for this
|
|
# task (unranked, unavailable, or failed its last probe) — the single signal
|
|
# FALLBACK mode acts on.
|
|
current_is_usable: bool = True
|
|
decision: Any = None # original SwitchDecision, for the UI dialog
|
|
|
|
@property
|
|
def has_target(self) -> bool:
|
|
"""A switch is only actionable when the engine named a model to move to."""
|
|
return bool(self.target_model or self.target_provider)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RoutingOutcome:
|
|
"""What the calling surface should actually do for this turn.
|
|
|
|
A surface needs exactly three things from routing — "which provider/model do
|
|
I build?", "do I tell the user?" and "was I told to stand down?" — so those
|
|
are the fields here, and nothing else. ``provider``/``model`` are ``None``
|
|
when the surface should keep its own selection untouched.
|
|
"""
|
|
|
|
mode: RoutingMode
|
|
switched: bool = False
|
|
provider: Optional[str] = None
|
|
model: Optional[str] = None
|
|
task_type: str = ""
|
|
score_gain: float = 0.0
|
|
reason: str = ""
|
|
# True when Manual mode proposed a switch and the user declined or the
|
|
# confirmation timed out. Distinct from "no switch proposed" so a surface
|
|
# can tell "routing had nothing to offer" from "the user said no".
|
|
declined: bool = False
|
|
decision: Any = field(default=None, repr=False)
|
|
|
|
@property
|
|
def should_notify(self) -> bool:
|
|
"""Whether the surface should post the "switched model" status bubble.
|
|
Only an executed switch is worth interrupting the transcript for."""
|
|
return self.switched
|
|
|
|
@classmethod
|
|
def keep_current(
|
|
cls,
|
|
mode: RoutingMode,
|
|
*,
|
|
reason: str = "",
|
|
task_type: str = "",
|
|
declined: bool = False,
|
|
decision: Any = None,
|
|
) -> "RoutingOutcome":
|
|
"""The no-change outcome — the single constructor for every path that
|
|
leaves the surface's own model selection in place (routing off, empty
|
|
prompt, no better candidate, user declined, internal error)."""
|
|
return cls(
|
|
mode=mode, switched=False, provider=None, model=None,
|
|
task_type=task_type, reason=reason, declined=declined, decision=decision,
|
|
)
|
|
|
|
|
|
__all__ = ["RoutingMode", "RoutingRequest", "RouteEvaluation", "RoutingOutcome"]
|