eyra 4.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chat/__init__.py +0 -0
- chat/capture.py +153 -0
- chat/complexity_scorer.py +330 -0
- chat/message_handler.py +270 -0
- chat/session_state.py +14 -0
- clients/__init__.py +0 -0
- clients/ai_client.py +508 -0
- clients/base_client.py +131 -0
- eyra-4.3.4.dist-info/METADATA +299 -0
- eyra-4.3.4.dist-info/RECORD +102 -0
- eyra-4.3.4.dist-info/WHEEL +4 -0
- eyra-4.3.4.dist-info/entry_points.txt +8 -0
- eyra-4.3.4.dist-info/licenses/LICENSE +113 -0
- main.py +128 -0
- runtime/__init__.py +0 -0
- runtime/actions.py +185 -0
- runtime/capabilities.py +206 -0
- runtime/certification.py +2690 -0
- runtime/cli.py +1207 -0
- runtime/coding_jobs.py +28 -0
- runtime/connectors/__init__.py +0 -0
- runtime/connectors/certification.py +136 -0
- runtime/connectors/cli.py +52 -0
- runtime/connectors/manifest.py +305 -0
- runtime/connectors/registry.py +218 -0
- runtime/connectors/runner.py +368 -0
- runtime/connectors/types.py +179 -0
- runtime/context.py +85 -0
- runtime/dictation.py +97 -0
- runtime/examples.py +47 -0
- runtime/external_agents.py +355 -0
- runtime/history/__init__.py +38 -0
- runtime/history/protocol.py +35 -0
- runtime/history/redaction.py +140 -0
- runtime/history/store.py +155 -0
- runtime/history/types.py +123 -0
- runtime/intents.py +90 -0
- runtime/jobs.py +523 -0
- runtime/live_session.py +2589 -0
- runtime/memory/__init__.py +11 -0
- runtime/memory/compression.py +48 -0
- runtime/memory/files.py +95 -0
- runtime/memory/mcp_client.py +181 -0
- runtime/memory/policy.py +35 -0
- runtime/memory/service.py +241 -0
- runtime/models.py +99 -0
- runtime/operator_loop.py +55 -0
- runtime/planner.py +193 -0
- runtime/preflight.py +433 -0
- runtime/privacy.py +108 -0
- runtime/resources/EyraMenuBar/Package.swift +29 -0
- runtime/resources/EyraMenuBar/Sources/EyraMenuBar/AppState.swift +147 -0
- runtime/resources/EyraMenuBar/Sources/EyraMenuBar/EyraCLI.swift +64 -0
- runtime/resources/EyraMenuBar/Sources/EyraMenuBar/EyraMenuBarApp.swift +22 -0
- runtime/resources/EyraMenuBar/Sources/EyraMenuBar/MenuViews.swift +188 -0
- runtime/resources/EyraMenuBar/Sources/EyraMenuBarCore/StatusModel.swift +67 -0
- runtime/resources/EyraMenuBar/Tests/EyraMenuBarTests/StatusModelTests.swift +58 -0
- runtime/resources/scripts/build_menu_bar_app.sh +89 -0
- runtime/routing/__init__.py +6 -0
- runtime/routing/effort.py +21 -0
- runtime/routing/fallback.py +61 -0
- runtime/routing/model_registry.py +158 -0
- runtime/routing/router.py +352 -0
- runtime/routing/tool_policy.py +350 -0
- runtime/routing/trace.py +80 -0
- runtime/routing/types.py +180 -0
- runtime/service.py +184 -0
- runtime/settings_catalog.py +131 -0
- runtime/shared.py +130 -0
- runtime/speech_controller.py +201 -0
- runtime/startup.py +550 -0
- runtime/status_presenter.py +186 -0
- runtime/tasks.py +370 -0
- runtime/tooling.py +244 -0
- runtime/triggers.py +344 -0
- runtime/vision.py +80 -0
- runtime/voice_diagnostics.py +529 -0
- runtime/voice_input.py +299 -0
- tools/__init__.py +6 -0
- tools/approval.py +121 -0
- tools/base.py +53 -0
- tools/browser.py +567 -0
- tools/clipboard.py +34 -0
- tools/filesystem.py +1146 -0
- tools/macos_context.py +94 -0
- tools/mcp_stdio.py +224 -0
- tools/operator.py +1692 -0
- tools/pdf.py +115 -0
- tools/registry.py +79 -0
- tools/screenshot.py +68 -0
- tools/system_info.py +106 -0
- tools/time_tool.py +19 -0
- tools/weather.py +53 -0
- utils/__init__.py +0 -0
- utils/image_history.py +55 -0
- utils/mock_client.py +60 -0
- utils/semantic_history.py +16 -0
- utils/settings.py +261 -0
- utils/sound_player.py +51 -0
- utils/theme.py +14 -0
- web/__init__.py +1 -0
- web/server.py +2470 -0
chat/__init__.py
ADDED
|
File without changes
|
chat/capture.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""In-memory screenshot capture and base64 encoding via mss + Pillow."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
|
|
8
|
+
import mss
|
|
9
|
+
from PIL import Image
|
|
10
|
+
|
|
11
|
+
from utils.sound_player import play_sound
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _validate_pil_image(img: Image.Image) -> bool:
|
|
17
|
+
"""
|
|
18
|
+
Validate that 'img' is a non-empty, valid PIL Image.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
bool: True if 'img' is valid; False otherwise.
|
|
22
|
+
"""
|
|
23
|
+
if not img:
|
|
24
|
+
logger.error("Validation failed: Image object is None.")
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
if not isinstance(img, Image.Image):
|
|
28
|
+
logger.error("Validation failed: Provided object is not a PIL Image.")
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
width, height = img.size
|
|
32
|
+
if width == 0 or height == 0:
|
|
33
|
+
logger.warning("Validation failed: Image has zero width or height.")
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _encode_pil_image_in_memory(
|
|
40
|
+
img: Image.Image, max_size: tuple[int, int] = (1024, 728), quality: int = 85
|
|
41
|
+
) -> str:
|
|
42
|
+
"""
|
|
43
|
+
1) Optionally resizes 'img' if it's larger than 'max_size'.
|
|
44
|
+
2) Converts to "RGB" if needed.
|
|
45
|
+
3) Removes all metadata.
|
|
46
|
+
4) Saves to an in-memory buffer (JPEG) with the specified 'quality'.
|
|
47
|
+
5) Returns the base64-encoded string of that buffer.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
img (Image.Image): The PIL Image to encode.
|
|
51
|
+
max_size (tuple[int, int]): (width, height) limit for in-memory resize.
|
|
52
|
+
quality (int): JPEG compression quality (1-100).
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If 'img' is invalid or if the in-memory buffer is empty.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
str: The base64-encoded JPEG data.
|
|
59
|
+
"""
|
|
60
|
+
if not _validate_pil_image(img):
|
|
61
|
+
raise ValueError("Invalid in-memory image; cannot encode.")
|
|
62
|
+
|
|
63
|
+
logger.debug(f"Original capture size: {img.size}, mode: {img.mode}")
|
|
64
|
+
|
|
65
|
+
# Resize if needed
|
|
66
|
+
if img.width > max_size[0] or img.height > max_size[1]:
|
|
67
|
+
logger.debug("Resizing image in-memory before encoding.")
|
|
68
|
+
img.thumbnail(max_size, Image.Resampling.LANCZOS)
|
|
69
|
+
|
|
70
|
+
# Convert to RGB if needed
|
|
71
|
+
if img.mode in ("RGBA", "P", "CMYK", "L"):
|
|
72
|
+
img = img.convert("RGB")
|
|
73
|
+
|
|
74
|
+
# Remove metadata
|
|
75
|
+
img.info = {}
|
|
76
|
+
|
|
77
|
+
# Save to a BytesIO buffer
|
|
78
|
+
buf = BytesIO()
|
|
79
|
+
img.save(
|
|
80
|
+
buf,
|
|
81
|
+
format="JPEG",
|
|
82
|
+
quality=quality,
|
|
83
|
+
optimize=True,
|
|
84
|
+
progressive=True,
|
|
85
|
+
subsampling=2, # 4:2:0 subsampling
|
|
86
|
+
)
|
|
87
|
+
buf.seek(0)
|
|
88
|
+
|
|
89
|
+
data = buf.read()
|
|
90
|
+
if not data:
|
|
91
|
+
raise ValueError("No data found in the in-memory buffer after saving.")
|
|
92
|
+
|
|
93
|
+
encoded = base64.b64encode(data).decode("utf-8")
|
|
94
|
+
if not encoded:
|
|
95
|
+
raise ValueError("Base64-encoded string is empty.")
|
|
96
|
+
|
|
97
|
+
logger.debug(f"Encoded image size in base64: {len(encoded)} characters")
|
|
98
|
+
return encoded
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def capture_screenshot_in_memory() -> Image.Image:
|
|
102
|
+
"""
|
|
103
|
+
Capture the primary monitor's screenshot directly into a PIL Image
|
|
104
|
+
without writing to disk (using 'mss').
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
PIL.Image: The screenshot as an in-memory PIL Image.
|
|
108
|
+
"""
|
|
109
|
+
import asyncio
|
|
110
|
+
|
|
111
|
+
await play_sound("camera")
|
|
112
|
+
|
|
113
|
+
def _grab():
|
|
114
|
+
with mss.mss() as sct:
|
|
115
|
+
monitor = sct.monitors[1] if len(sct.monitors) > 1 else sct.monitors[0]
|
|
116
|
+
raw_screenshot = sct.grab(monitor)
|
|
117
|
+
return Image.frombytes(
|
|
118
|
+
"RGB", (raw_screenshot.width, raw_screenshot.height), raw_screenshot.rgb
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
img = await asyncio.to_thread(_grab)
|
|
122
|
+
logger.info("Screenshot captured in memory.")
|
|
123
|
+
return img
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def _capture_and_encode(
|
|
127
|
+
capture_func: Callable[[], Awaitable[Image.Image]],
|
|
128
|
+
max_size: tuple[int, int],
|
|
129
|
+
quality: int,
|
|
130
|
+
) -> str:
|
|
131
|
+
"""
|
|
132
|
+
Private helper to unify the flow of capture + encode.
|
|
133
|
+
"""
|
|
134
|
+
pil_image = await capture_func()
|
|
135
|
+
return _encode_pil_image_in_memory(pil_image, max_size, quality)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def capture_screenshot_and_encode(
|
|
139
|
+
max_size: tuple[int, int] = (1024, 728), quality: int = 99
|
|
140
|
+
) -> str:
|
|
141
|
+
"""
|
|
142
|
+
Capture a screenshot entirely in memory, then encode it as a base64 JPEG.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
max_size (Tuple[int,int]): Resize limit for final image if needed.
|
|
146
|
+
quality (int): JPEG compression quality.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
str: Base64-encoded JPEG data.
|
|
150
|
+
"""
|
|
151
|
+
return await _capture_and_encode(
|
|
152
|
+
capture_func=capture_screenshot_in_memory, max_size=max_size, quality=quality
|
|
153
|
+
)
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deterministic prompt router for Eyra.
|
|
3
|
+
|
|
4
|
+
Routes user prompts to Simple, Moderate, or Complex models using
|
|
5
|
+
pattern matching and weighted signal scoring. No ML dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Public types (unchanged interface)
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
class ComplexityLevel(str, Enum):
|
|
21
|
+
SIMPLE = "Simple"
|
|
22
|
+
MODERATE = "Moderate"
|
|
23
|
+
COMPLEX = "Complex"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ComplexityResponse(BaseModel):
|
|
27
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
28
|
+
|
|
29
|
+
classification: ComplexityLevel = Field(..., description="Complexity level.")
|
|
30
|
+
confidence: float = Field(..., ge=0, le=1, description="Confidence [0..1]")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Hard-match patterns
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
_SIMPLE_PATTERNS: list[re.Pattern] = [
|
|
38
|
+
# Greetings
|
|
39
|
+
re.compile(r"^(hi|hello|hey|yo|sup|howdy|hola|morning|evening|afternoon)[\s!?.]*$", re.I),
|
|
40
|
+
# Acknowledgments and thanks
|
|
41
|
+
re.compile(r"^(thanks?|thank\s*you|thx|ty|cheers|appreciated|cool|nice|great|awesome|perfect|ok(ay)?|sure|got\s*it|noted|done|right|yep|yup|nope|nah)[\s!?.]*$", re.I),
|
|
42
|
+
# Yes / no
|
|
43
|
+
re.compile(r"^(yes|no|yeah|nah|yep|yup|nope|y|n)[\s!?.]*$", re.I),
|
|
44
|
+
# Farewells
|
|
45
|
+
re.compile(r"^(bye|goodbye|see\s*ya|later|ciao|goodnight|gn)[\s!?.]*$", re.I),
|
|
46
|
+
# Tiny social turns
|
|
47
|
+
re.compile(r"^(lol|haha|heh|wow|omg|bruh|nice one|fair enough|same|true)[\s!?.]*$", re.I),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
_COMPLEX_CODE_PATTERNS: list[re.Pattern] = [
|
|
51
|
+
re.compile(r"\b(implement|write|code|build|create)\b.{0,40}\b(function|class|module|script|program|api|endpoint|server|parser|compiler|handler|service|component|tree|queue|stack|graph|struct|iterator)\b", re.I),
|
|
52
|
+
re.compile(r"\b(debug|fix|patch|troubleshoot|diagnose)\b.{0,30}\b(bug|error|issue|crash|exception|failure|segfault)\b", re.I),
|
|
53
|
+
re.compile(r"\b(debug|fix|patch|rewrite|optimize)\b.{0,20}\b(this\s+)?(regex|query|sql|css|code|script|function|loop|algorithm)\b", re.I),
|
|
54
|
+
re.compile(r"\b(review|audit|inspect)\b.{0,30}\b(code|pull\s*request|pr|commit|diff|security)\b", re.I),
|
|
55
|
+
re.compile(r"\b(refactor|rewrite|optimize|redesign)\b.{0,30}\b(code|function|class|module|system|architecture)\b", re.I),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
_COMPLEX_ANALYSIS_PATTERNS: list[re.Pattern] = [
|
|
59
|
+
re.compile(r"\b(compare|contrast|evaluate|analyze|assess)\b.{0,80}\b(approach|methods?|algorithms?|architecture|frameworks?|strategy|strategies|trade.?offs?)\b", re.I),
|
|
60
|
+
re.compile(r"\b(design|architect|plan)\b.{0,30}\b(system|schema|database|pipeline|workflow|infrastructure)\b", re.I),
|
|
61
|
+
re.compile(r"\b(design|architect|plan)\b.{0,80}\b(scheduler|worker\s+pool|task\s+(queue|runner|scheduler|system)|job\s+queue|retries?|cancell?ation|persistence)\b", re.I),
|
|
62
|
+
re.compile(r"\b(prove|derive|formalize)\b", re.I),
|
|
63
|
+
re.compile(r"\bmulti.?step\b|\bstep.?by.?step\b.{0,20}\b(plan|solution|approach)\b", re.I),
|
|
64
|
+
re.compile(r"\b(expert|in.?depth|thorough|comprehensive|detailed)\b.{0,20}\b(analysis|review|assessment|explanation)\b", re.I),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Scoring cues (weighted signal lists)
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
_REASONING_CUES = [
|
|
72
|
+
"why", "how does", "explain", "what causes", "reason", "because",
|
|
73
|
+
"trade-off", "trade-offs", "tradeoff", "tradeoffs", "pros and cons", "implications", "consequence",
|
|
74
|
+
"assumption", "constraint", "caveat",
|
|
75
|
+
"difference between", "differences between", "distinguish",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
_CODE_DEBUG_CUES = [
|
|
79
|
+
"function", "class", "method", "variable", "loop", "recursion",
|
|
80
|
+
"algorithm", "regex", "sql", "query", "api", "endpoint", "http",
|
|
81
|
+
"error", "bug", "stack trace", "exception", "crash", "lint",
|
|
82
|
+
"test", "unit test", "integration", "deploy", "docker", "ci/cd",
|
|
83
|
+
"git", "branch", "merge", "commit", "typescript", "python", "rust",
|
|
84
|
+
"javascript", "java", "kotlin", "swift", "go", "golang", "c++",
|
|
85
|
+
"bash", "shell", "terminal", "command line", "async", "scheduler",
|
|
86
|
+
"task queue", "worker pool", "retry", "retries", "cancellation", "persistence",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
_STRUCTURED_OUTPUT_CUES = [
|
|
90
|
+
"table", "json", "yaml", "xml", "csv", "markdown", "format as",
|
|
91
|
+
"schema", "template", "spec", "specification",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
# Curated domain terms: ~80 high-signal exact phrases
|
|
95
|
+
_DOMAIN_TERMS = frozenset([
|
|
96
|
+
# CS / programming
|
|
97
|
+
"algorithm", "data structure", "time complexity", "space complexity",
|
|
98
|
+
"dynamic programming", "recursion", "binary search", "hash map",
|
|
99
|
+
"linked list", "tree traversal", "graph algorithm", "sorting",
|
|
100
|
+
"concurrency", "mutex", "semaphore", "deadlock", "race condition",
|
|
101
|
+
"design pattern", "dependency injection", "microservice",
|
|
102
|
+
# ML / AI
|
|
103
|
+
"machine learning", "deep learning", "neural network", "neural networks", "transformer",
|
|
104
|
+
"gradient descent", "backpropagation", "loss function", "overfitting",
|
|
105
|
+
"reinforcement learning", "attention mechanism", "fine-tuning",
|
|
106
|
+
"embedding", "tokenizer", "inference", "training",
|
|
107
|
+
# Math
|
|
108
|
+
"linear algebra", "calculus", "differential equation", "probability",
|
|
109
|
+
"statistics", "eigenvalue", "matrix", "fourier transform",
|
|
110
|
+
"optimization", "convex", "stochastic",
|
|
111
|
+
# Infra / DevOps
|
|
112
|
+
"kubernetes", "docker", "ci/cd", "load balancer", "reverse proxy",
|
|
113
|
+
"database", "replication", "sharding", "cache invalidation",
|
|
114
|
+
"message queue", "event-driven", "serverless",
|
|
115
|
+
# Security
|
|
116
|
+
"encryption", "authentication", "authorization", "vulnerability",
|
|
117
|
+
"sql injection", "xss", "csrf", "zero-day", "penetration testing",
|
|
118
|
+
"cryptography", "certificate", "tls", "oauth",
|
|
119
|
+
# Systems
|
|
120
|
+
"operating system", "kernel", "memory management", "file system",
|
|
121
|
+
"process", "thread", "scheduler", "task scheduler", "worker pool", "job queue", "virtual memory",
|
|
122
|
+
"compiler", "interpreter", "garbage collection",
|
|
123
|
+
# Physics / quantum
|
|
124
|
+
"qubit", "quantum", "relativity", "superposition", "entanglement",
|
|
125
|
+
])
|
|
126
|
+
|
|
127
|
+
# Short referential follow-ups that should inherit prior context
|
|
128
|
+
_FOLLOWUP_PATTERNS: list[re.Pattern] = [
|
|
129
|
+
re.compile(r"^(explain|elaborate|tell me)\s*(more|further|again)[\s?.!]*$", re.I),
|
|
130
|
+
re.compile(r"^(why|how|what)[\s?!.]*$", re.I),
|
|
131
|
+
re.compile(r"^(continue|go on|keep going|more)[\s?.!]*$", re.I),
|
|
132
|
+
re.compile(r"^(rewrite|redo|do)\s*(it|that|this)(.{0,20})$", re.I),
|
|
133
|
+
re.compile(r"^(now\s+)?(in|using|with)\s+\w+[\s?.!]*$", re.I),
|
|
134
|
+
re.compile(r"^(can you|could you|please)\s+(expand|clarify|rephrase)[\s?.!]*", re.I),
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Scorer
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
class ComplexityScorer:
|
|
143
|
+
"""
|
|
144
|
+
Deterministic prompt router. No ML models, no heavy dependencies.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(self):
|
|
148
|
+
logger.info("ComplexityScorer (deterministic router) initialized.")
|
|
149
|
+
|
|
150
|
+
async def score_complexity(
|
|
151
|
+
self,
|
|
152
|
+
text_content: str | None,
|
|
153
|
+
messages: list[dict] | None = None,
|
|
154
|
+
) -> ComplexityResponse:
|
|
155
|
+
text = (text_content or "").strip()
|
|
156
|
+
|
|
157
|
+
# 1. Hard-match simple
|
|
158
|
+
if text and self._matches_any(text, _SIMPLE_PATTERNS):
|
|
159
|
+
result = ComplexityResponse(classification=ComplexityLevel.SIMPLE, confidence=0.90)
|
|
160
|
+
logger.info(f"[Router] hard-simple -> {result.classification}")
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
# 2. Hard-match complex
|
|
164
|
+
if text and self._matches_any(text, _COMPLEX_CODE_PATTERNS + _COMPLEX_ANALYSIS_PATTERNS):
|
|
165
|
+
result = ComplexityResponse(classification=ComplexityLevel.COMPLEX, confidence=0.90)
|
|
166
|
+
logger.info(f"[Router] hard-complex -> {result.classification}")
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
# 3. Score-based routing
|
|
170
|
+
score = self._compute_text_score(text, messages)
|
|
171
|
+
result = self._classify_from_score(score)
|
|
172
|
+
logger.info(f"[Router] score={score:.3f} -> {result.classification} (conf={result.confidence:.2f})")
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
# ----- Text score computation -----
|
|
176
|
+
|
|
177
|
+
def _compute_text_score(self, text: str, messages: list[dict] | None) -> float:
|
|
178
|
+
if not text:
|
|
179
|
+
return 0.0
|
|
180
|
+
|
|
181
|
+
text_lower = text.lower()
|
|
182
|
+
words = text_lower.split()
|
|
183
|
+
score = 0.0
|
|
184
|
+
|
|
185
|
+
# Reasoning cues (weight: 0.15 each, max ~0.45)
|
|
186
|
+
reasoning_hits = sum(1 for cue in _REASONING_CUES if re.search(r'\b' + re.escape(cue.strip()) + r'\b', text_lower))
|
|
187
|
+
score += min(0.45, reasoning_hits * 0.15)
|
|
188
|
+
|
|
189
|
+
# Code/debug cues (weight: 0.10 each, max ~0.40)
|
|
190
|
+
code_hits = sum(1 for cue in _CODE_DEBUG_CUES if self._cue_matches(cue, text_lower))
|
|
191
|
+
score += min(0.40, code_hits * 0.10)
|
|
192
|
+
|
|
193
|
+
# Structured output cues (weight: 0.10 each, max ~0.20)
|
|
194
|
+
struct_hits = sum(1 for cue in _STRUCTURED_OUTPUT_CUES if re.search(r'\b' + re.escape(cue.strip()) + r'\b', text_lower))
|
|
195
|
+
score += min(0.20, struct_hits * 0.10)
|
|
196
|
+
|
|
197
|
+
# Domain terms (weight: 0.12 each, max ~0.36)
|
|
198
|
+
domain_hits = sum(1 for term in _DOMAIN_TERMS if re.search(r'\b' + re.escape(term) + r'\b', text_lower))
|
|
199
|
+
score += min(0.36, domain_hits * 0.12)
|
|
200
|
+
|
|
201
|
+
# Constraint count: sentences with "must", "should", "require", "need to", "ensure"
|
|
202
|
+
constraint_words = ["must", "should", "require", "need to", "ensure", "make sure"]
|
|
203
|
+
constraint_hits = sum(1 for c in constraint_words if re.search(r'\b' + re.escape(c.strip()) + r'\b', text_lower))
|
|
204
|
+
score += min(0.20, constraint_hits * 0.10)
|
|
205
|
+
|
|
206
|
+
# Prompt length bonus (longer prompts tend toward complexity)
|
|
207
|
+
if len(words) > 50:
|
|
208
|
+
score += 0.15
|
|
209
|
+
elif len(words) > 25:
|
|
210
|
+
score += 0.08
|
|
211
|
+
|
|
212
|
+
# Short prompt with code cues: ensure at least Moderate threshold
|
|
213
|
+
if len(words) <= 8 and code_hits > 0:
|
|
214
|
+
score = max(score, 0.30)
|
|
215
|
+
|
|
216
|
+
# Very short prompt (1-3 words) with domain terms: ensure at least Moderate
|
|
217
|
+
if len(words) <= 3 and domain_hits > 0:
|
|
218
|
+
score = max(score, 0.30)
|
|
219
|
+
|
|
220
|
+
# Question mark alone (short question) slight reduction,
|
|
221
|
+
# but only if no domain terms or code cues were found
|
|
222
|
+
if text.endswith("?") and len(words) <= 6 and score < 0.4 and domain_hits == 0 and code_hits == 0:
|
|
223
|
+
score *= 0.7
|
|
224
|
+
|
|
225
|
+
# Follow-up context inheritance
|
|
226
|
+
if messages and self._is_followup(text):
|
|
227
|
+
prior_level = self._get_prior_complexity(messages)
|
|
228
|
+
if prior_level is not None:
|
|
229
|
+
# Blend: use at least the prior level
|
|
230
|
+
prior_score = {
|
|
231
|
+
ComplexityLevel.SIMPLE: 0.15,
|
|
232
|
+
ComplexityLevel.MODERATE: 0.45,
|
|
233
|
+
ComplexityLevel.COMPLEX: 0.75,
|
|
234
|
+
}.get(prior_level, 0.0)
|
|
235
|
+
score = max(score, prior_score)
|
|
236
|
+
|
|
237
|
+
return min(1.0, score)
|
|
238
|
+
|
|
239
|
+
def _is_followup(self, text: str) -> bool:
|
|
240
|
+
"""Check if text is a short referential follow-up."""
|
|
241
|
+
if len(text.split()) > 8:
|
|
242
|
+
return False
|
|
243
|
+
return self._matches_any(text, _FOLLOWUP_PATTERNS)
|
|
244
|
+
|
|
245
|
+
def _get_prior_complexity(self, messages: list[dict]) -> ComplexityLevel | None:
|
|
246
|
+
"""Look at the last 3 user messages for context."""
|
|
247
|
+
user_msgs = [m for m in messages if m.get("role") == "user"]
|
|
248
|
+
recent = user_msgs[-3:] if len(user_msgs) >= 3 else user_msgs
|
|
249
|
+
|
|
250
|
+
if not recent:
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
# Score the most recent substantial user message
|
|
254
|
+
for msg in reversed(recent):
|
|
255
|
+
content = msg.get("content", "")
|
|
256
|
+
if isinstance(content, list):
|
|
257
|
+
content = " ".join(
|
|
258
|
+
item.get("text", "") for item in content if item.get("type") == "text"
|
|
259
|
+
)
|
|
260
|
+
content = content.strip()
|
|
261
|
+
if not content or self._is_followup(content):
|
|
262
|
+
continue
|
|
263
|
+
return self._classify_text_standalone(content)
|
|
264
|
+
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
def _classify_text_standalone(self, text: str) -> ComplexityLevel:
|
|
268
|
+
"""Classify a text prompt using full routing logic (no follow-up recursion)."""
|
|
269
|
+
if self._matches_any(text, _SIMPLE_PATTERNS):
|
|
270
|
+
return ComplexityLevel.SIMPLE
|
|
271
|
+
if self._matches_any(text, _COMPLEX_CODE_PATTERNS + _COMPLEX_ANALYSIS_PATTERNS):
|
|
272
|
+
return ComplexityLevel.COMPLEX
|
|
273
|
+
score = self._compute_text_score(text, messages=None)
|
|
274
|
+
return self._classify_from_score(score).classification
|
|
275
|
+
|
|
276
|
+
# ----- Classification from score -----
|
|
277
|
+
|
|
278
|
+
def _classify_from_score(self, score: float) -> ComplexityResponse:
|
|
279
|
+
"""
|
|
280
|
+
Thresholds:
|
|
281
|
+
< 0.30 -> Simple
|
|
282
|
+
< 0.60 -> Moderate
|
|
283
|
+
>= 0.60 -> Complex
|
|
284
|
+
|
|
285
|
+
Confidence:
|
|
286
|
+
0.90 for hard-rule (handled upstream)
|
|
287
|
+
0.75 for clear margin from thresholds
|
|
288
|
+
0.60 for borderline
|
|
289
|
+
"""
|
|
290
|
+
if score < 0.30:
|
|
291
|
+
margin = (0.30 - score) / 0.30
|
|
292
|
+
confidence = 0.60 + 0.15 * margin # 0.60..0.75
|
|
293
|
+
return ComplexityResponse(classification=ComplexityLevel.SIMPLE, confidence=round(confidence, 2))
|
|
294
|
+
elif score < 0.60:
|
|
295
|
+
# Distance from nearest boundary
|
|
296
|
+
dist_low = score - 0.30
|
|
297
|
+
dist_high = 0.60 - score
|
|
298
|
+
margin = min(dist_low, dist_high) / 0.15
|
|
299
|
+
confidence = 0.60 + 0.15 * margin
|
|
300
|
+
return ComplexityResponse(classification=ComplexityLevel.MODERATE, confidence=round(confidence, 2))
|
|
301
|
+
else:
|
|
302
|
+
margin = min(1.0, (score - 0.60) / 0.40)
|
|
303
|
+
confidence = 0.60 + 0.15 * margin
|
|
304
|
+
return ComplexityResponse(classification=ComplexityLevel.COMPLEX, confidence=round(confidence, 2))
|
|
305
|
+
|
|
306
|
+
# ----- Helpers -----
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def _matches_any(text: str, patterns: list[re.Pattern]) -> bool:
|
|
310
|
+
return any(p.search(text) for p in patterns)
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _cue_matches(cue: str, text_lower: str) -> bool:
|
|
314
|
+
"""Match a scoring cue without treating ordinary English as code."""
|
|
315
|
+
normalized = cue.strip().lower()
|
|
316
|
+
if normalized == "go":
|
|
317
|
+
# "go" is common English. Count it as the language only near code/language cues.
|
|
318
|
+
return bool(
|
|
319
|
+
re.search(
|
|
320
|
+
r"\b(?:write|use|using|in|with|learn|code|programming|language)\b.{0,30}\bgo\b"
|
|
321
|
+
r"|\bgo\b.{0,30}\b(?:code|program|programming|function|module|package|language)\b",
|
|
322
|
+
text_lower,
|
|
323
|
+
re.I,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
if normalized == "c++":
|
|
327
|
+
return bool(re.search(r"(?<!\w)c\+\+(?!\w)", text_lower, re.I))
|
|
328
|
+
if normalized and normalized[0].isalnum() and normalized[-1].isalnum():
|
|
329
|
+
return bool(re.search(r"\b" + re.escape(normalized) + r"\b", text_lower))
|
|
330
|
+
return normalized in text_lower
|