Implement Phase 1 of Planning Exercise Suggestion with Scenario Pipeline and LLM Intent Overlay
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m14s
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m14s
- Introduced the Scenario Pipeline for planning exercises, allowing for more nuanced query handling and exercise suggestions based on user intent. - Enhanced the `suggestPlanningExercises` API to include `include_llm_intent`, `scenario_kind`, and `query_intent_summary`, improving the context provided to the frontend. - Updated the `ExercisePickerModal` to display new information related to query intent and scenario classification, enhancing user experience during exercise selection. - Incremented application version to 0.8.171 and updated changelog to document the new features and improvements in the planning AI capabilities.
This commit is contained in:
parent
207817376d
commit
45e3b5f4f6
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**Version:** 0.1
|
**Version:** 0.1
|
||||||
**Datum:** 2026-05-22
|
**Datum:** 2026-05-22
|
||||||
**Status:** P0.1 — Hybrid-Retrieval + Phase-1-Profil-Score (`profile_v1`); LLM-Rerank P2
|
**Status:** P1 — Szenario-Pipeline + LLM Query-Intent-Overlay; P2 LLM-Rerank optional
|
||||||
**Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph)
|
**Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -25,7 +25,7 @@ Trainer in der **Trainingsplanung** sollen Übungen finden oder anlegen können
|
||||||
| Stufe | Name | Technik | P0 |
|
| Stufe | Name | Technik | P0 |
|
||||||
|-------|------|---------|-----|
|
|-------|------|---------|-----|
|
||||||
| **S0** | Kontext-Pack | SQL/API, deterministisch | ✅ |
|
| **S0** | Kontext-Pack | SQL/API, deterministisch | ✅ |
|
||||||
| **S1a** | Intent strukturieren | Optional LLM `planning_exercise_search_intent` | Heuristik |
|
| **S1a** | Intent strukturieren | LLM `planning_exercise_search_intent` (Szenario-Pipeline) | ✅ P1 |
|
||||||
| **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan + **Profil** | ✅ |
|
| **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan + **Profil** | ✅ |
|
||||||
| **S1b+** | Profil-Vorselektion | `ExerciseMatchProfile` × `PlanningTargetProfile` | ✅ `profile_v1` |
|
| **S1b+** | Profil-Vorselektion | `ExerciseMatchProfile` × `PlanningTargetProfile` | ✅ `profile_v1` |
|
||||||
| **S1c** | Rerank + Begründung | Optional LLM `planning_exercise_search_rank` | Regelbasierte `reasons[]` |
|
| **S1c** | Rerank + Begründung | Optional LLM `planning_exercise_search_rank` | Regelbasierte `reasons[]` |
|
||||||
|
|
@ -179,7 +179,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
||||||
| **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung |
|
| **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung |
|
||||||
| **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` |
|
| **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` |
|
||||||
| **P2** ✅ (optional) | LLM-Rerank `planning_exercise_search_rank`, `include_llm_rank`, `llm_rank_applied` |
|
| **P2** ✅ (optional) | LLM-Rerank `planning_exercise_search_rank`, `include_llm_rank`, `llm_rank_applied` |
|
||||||
| **P1** | LLM Intent-JSON; Neu-Anlage mit Pack |
|
| **P1** ✅ | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil-Overlay |
|
||||||
| **P3** | Skill-Discovery / Framework-Ziele im Pack |
|
| **P3** | Skill-Discovery / Framework-Ziele im Pack |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -197,7 +197,55 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
||||||
|
|
||||||
- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage).
|
- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage).
|
||||||
- **Progressionsgraph-ID:** noch nicht aus UI wählbar (`progression_graph_id` nur per API).
|
- **Progressionsgraph-ID:** noch nicht aus UI wählbar (`progression_graph_id` nur per API).
|
||||||
- **LLM-Intent:** P1 laut Roadmap §9.
|
- **LLM-Intent:** ✅ P1 Szenario-Pipeline + `planning_exercise_search_intent` (Migration 073).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Szenario-Pipeline & Query-Erwartungsprofil (P1)
|
||||||
|
|
||||||
|
Komplexe Planungsanfragen brauchen **Schritte vor** dem Profil-Match — nicht jede Query ist gleich.
|
||||||
|
|
||||||
|
### 16.1 Szenario-Klassen
|
||||||
|
|
||||||
|
| `scenario_kind` | Typische Anfrage | LLM Intent? |
|
||||||
|
|-----------------|------------------|-------------|
|
||||||
|
| `preset_next` | „Nächste Übung vorschlagen“ (Preset) | Nein — nur Basis-Profil |
|
||||||
|
| `progression` | Progressionsgraph / Pfad | Ja (wenn Freitext) |
|
||||||
|
| `deepen` | Vertiefung Anker | Ja |
|
||||||
|
| `continue_plan` | Auf bisherigen Plan aufbauen | Ja |
|
||||||
|
| `additive_constraint` | Plan **+** Zusatz (z. B. Schnellkraft) | Ja |
|
||||||
|
| `free_search` | Offene Stichwortsuche | Ja |
|
||||||
|
|
||||||
|
**Routing:** `planning_exercise_target_pipeline.classify_planning_scenario()` → `should_run_llm_intent_pipeline()`.
|
||||||
|
|
||||||
|
### 16.2 Pipeline (Reihenfolge)
|
||||||
|
|
||||||
|
```
|
||||||
|
S0 Kontext-Pack
|
||||||
|
→ Heuristik-Intent + Szenario
|
||||||
|
→ [optional] LLM planning_exercise_search_intent
|
||||||
|
→ Basis PlanningTargetProfile (Rahmen, Plan, Anker, Gap)
|
||||||
|
→ Merge Query-Overlay (Katalog-IDs aus Hints)
|
||||||
|
→ Hybrid-Retrieval + Profil-Score
|
||||||
|
→ [optional] LLM-Rerank
|
||||||
|
```
|
||||||
|
|
||||||
|
Module: `planning_exercise_target_pipeline.py` · `planning_exercise_intent.py`
|
||||||
|
|
||||||
|
### 16.3 API (Erweiterung)
|
||||||
|
|
||||||
|
| Request | Default | Bedeutung |
|
||||||
|
|---------|---------|-----------|
|
||||||
|
| `include_llm_intent` | `true` | LLM nur wenn Szenario ≠ preset_next und Query nicht leer |
|
||||||
|
|
||||||
|
| Response | Bedeutung |
|
||||||
|
|----------|-----------|
|
||||||
|
| `scenario_kind` | Szenario-Klasse |
|
||||||
|
| `query_intent_summary` | intent, llm_applied, rationale, skill_hints_resolved |
|
||||||
|
| `intent_heuristic` | Heuristik vor LLM |
|
||||||
|
| `retrieval_phase` | z. B. `profile_v1+query_intent+llm_rank` |
|
||||||
|
|
||||||
|
**Prompt 073:** `planning_exercise_search_intent` — Ausgabe JSON mit `skill_hints`, `focus_hints`, `emphasis` (`additive`|`replace`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from prompt_resolver import MustacheRenderResult, render_mustache_template
|
||||||
_PLANNING_AI_SLUGS = frozenset(
|
_PLANNING_AI_SLUGS = frozenset(
|
||||||
{
|
{
|
||||||
"planning_exercise_search_rank",
|
"planning_exercise_search_rank",
|
||||||
|
"planning_exercise_search_intent",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
-- Migration 073: KI-Prompt Planungs-Übungssuche — Intent/Query-Overlay (P1)
|
||||||
|
-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §16
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_exercise_search_intent',
|
||||||
|
'Planungs-Übungssuche Intent',
|
||||||
|
'Strukturiert Freitext-Anfrage in Intent, Szenario und Katalog-Hints für Erwartungsprofil-Overlay.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer in der Trainingsplanung.
|
||||||
|
Analysiere die Suchanfrage im Kontext der Einheit und des bisherigen Plans.
|
||||||
|
|
||||||
|
Ziel: JSON für ein Erwartungsprofil-Overlay (Fähigkeiten, Fokus, Stil …) — NICHT Übungs-IDs erfinden.
|
||||||
|
|
||||||
|
Szenario-Klassen (scenario):
|
||||||
|
- preset_next: nur „nächste Übung“ ohne Zusatz — selten bei Freitext
|
||||||
|
- progression: Progressionsgraph / Pfad / Folgeübung im Graph
|
||||||
|
- deepen: Vertiefung zur Anker-Übung
|
||||||
|
- continue_plan: baut auf bisherigem Plan der Einheit auf
|
||||||
|
- additive_constraint: Plan beibehalten UND zusätzliche Anforderung (z. B. „außerdem Schnellkraft“)
|
||||||
|
- free_search: offene Stichwortsuche / neues Thema
|
||||||
|
|
||||||
|
Intent (intent): suggest_next | progression_next | deepen_exercise | continue_plan_goal | free_search
|
||||||
|
|
||||||
|
emphasis:
|
||||||
|
- additive: Zusatz zur bestehenden Planung (Default bei „zusätzlich/auch/dazu“)
|
||||||
|
- replace: Suchanfrage soll Schwerpunkt eher ersetzen
|
||||||
|
- neutral: nur leichte Gewichtung
|
||||||
|
|
||||||
|
Nutze skill_hints/focus_hints etc. mit Namen aus den Katalog-JSONs (beste Übereinstimmung).
|
||||||
|
Bei requires_partner: true/false/null wenn Partnerbezug erkennbar.
|
||||||
|
|
||||||
|
Eingabe:
|
||||||
|
Suchanfrage: {{search_query}}
|
||||||
|
Heuristik-Intent: {{heuristic_intent}}
|
||||||
|
Szenario-Hinweis (Server): {{scenario_hint}}
|
||||||
|
Planungskontext: {{planning_context_json}}
|
||||||
|
Basis-Zielprofil (deterministisch): {{target_profile_json}}
|
||||||
|
|
||||||
|
Kataloge (Auszug — nur diese Namen/IDs verwenden):
|
||||||
|
Skills: {{skills_catalog_json}}
|
||||||
|
Fokus: {{focus_areas_catalog_json}}
|
||||||
|
Trainingsstil: {{training_types_catalog_json}}
|
||||||
|
Stilrichtung: {{style_directions_catalog_json}}
|
||||||
|
Zielgruppe: {{target_groups_catalog_json}}
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"intent": "continue_plan_goal",
|
||||||
|
"scenario": "additive_constraint",
|
||||||
|
"skill_hints": [{"name": "Schnellkraft", "weight": 1.0}],
|
||||||
|
"focus_hints": [],
|
||||||
|
"style_hints": [],
|
||||||
|
"training_type_hints": [],
|
||||||
|
"target_group_hints": [],
|
||||||
|
"requires_partner": null,
|
||||||
|
"emphasis": "additive",
|
||||||
|
"rationale": "Kurz auf Deutsch, 1 Satz"
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","required":["intent","scenario"],"properties":{"intent":{"type":"string"},"scenario":{"type":"string"},"skill_hints":{"type":"array"},"emphasis":{"type":"string"},"rationale":{"type":"string"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
11
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_search_intent');
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET default_template = template
|
||||||
|
WHERE slug = 'planning_exercise_search_intent'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
272
backend/planning_exercise_intent.py
Normal file
272
backend/planning_exercise_intent.py
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
"""
|
||||||
|
P1: LLM-Intent aus Planungs-Suchfrage → strukturiertes Query-Overlay für PlanningTargetProfile.
|
||||||
|
|
||||||
|
Prompt: planning_exercise_search_intent (Migration 073)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||||
|
from openrouter_chat import (
|
||||||
|
effective_openrouter_model_for_prompt_row,
|
||||||
|
normalize_openrouter_env,
|
||||||
|
openrouter_chat_completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.planning_exercise_intent")
|
||||||
|
|
||||||
|
VALID_PARSED_INTENTS = {
|
||||||
|
"suggest_next",
|
||||||
|
"progression_next",
|
||||||
|
"deepen_exercise",
|
||||||
|
"continue_plan_goal",
|
||||||
|
"free_search",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_SCENARIOS = {
|
||||||
|
"preset_next",
|
||||||
|
"progression",
|
||||||
|
"deepen",
|
||||||
|
"continue_plan",
|
||||||
|
"additive_constraint",
|
||||||
|
"free_search",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_EMPHASIS = {"additive", "replace", "neutral"}
|
||||||
|
|
||||||
|
|
||||||
|
class SkillHint(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningQueryIntentParsed(BaseModel):
|
||||||
|
intent: str = "free_search"
|
||||||
|
scenario: str = "free_search"
|
||||||
|
skill_hints: List[SkillHint] = Field(default_factory=list)
|
||||||
|
focus_hints: List[str] = Field(default_factory=list)
|
||||||
|
style_hints: List[str] = Field(default_factory=list)
|
||||||
|
training_type_hints: List[str] = Field(default_factory=list)
|
||||||
|
target_group_hints: List[str] = Field(default_factory=list)
|
||||||
|
requires_partner: Optional[bool] = None
|
||||||
|
emphasis: str = "additive"
|
||||||
|
rationale: Optional[str] = Field(default=None, max_length=400)
|
||||||
|
|
||||||
|
@field_validator("intent")
|
||||||
|
@classmethod
|
||||||
|
def _intent(cls, v: str) -> str:
|
||||||
|
s = (v or "").strip().lower()
|
||||||
|
return s if s in VALID_PARSED_INTENTS else "free_search"
|
||||||
|
|
||||||
|
@field_validator("scenario")
|
||||||
|
@classmethod
|
||||||
|
def _scenario(cls, v: str) -> str:
|
||||||
|
s = (v or "").strip().lower()
|
||||||
|
return s if s in VALID_SCENARIOS else "free_search"
|
||||||
|
|
||||||
|
@field_validator("emphasis")
|
||||||
|
@classmethod
|
||||||
|
def _emphasis(cls, v: str) -> str:
|
||||||
|
s = (v or "").strip().lower()
|
||||||
|
return s if s in VALID_EMPHASIS else "additive"
|
||||||
|
|
||||||
|
@field_validator("focus_hints", "style_hints", "training_type_hints", "target_group_hints", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _str_list(cls, v: Any) -> List[str]:
|
||||||
|
if not v:
|
||||||
|
return []
|
||||||
|
if isinstance(v, str):
|
||||||
|
return [v.strip()] if v.strip() else []
|
||||||
|
out: List[str] = []
|
||||||
|
for item in v:
|
||||||
|
s = str(item or "").strip()
|
||||||
|
if s and s not in out:
|
||||||
|
out.append(s[:120])
|
||||||
|
return out[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: str) -> Dict[str, Any]:
|
||||||
|
s = (text or "").strip()
|
||||||
|
if s.startswith("```"):
|
||||||
|
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||||
|
if s.endswith("```"):
|
||||||
|
s = s[:-3].strip()
|
||||||
|
start = s.find("{")
|
||||||
|
end = s.rfind("}")
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
|
||||||
|
obj = json.loads(s[start : end + 1])
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def parse_planning_query_intent_response(text: str) -> PlanningQueryIntentParsed:
|
||||||
|
obj = _extract_json_object(text)
|
||||||
|
return PlanningQueryIntentParsed.model_validate(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_json(obj: Any) -> str:
|
||||||
|
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_compact_catalog(cur, table: str, id_col: str, name_col: str = "name", limit: int = 80) -> List[Dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {id_col} AS id, {name_col} AS name
|
||||||
|
FROM {table}
|
||||||
|
ORDER BY {name_col} ASC NULLS LAST
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
return [{"id": int(r["id"]), "name": str(r["name"] or "")[:80]} for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_skills_catalog_compact(cur, limit: int = 120) -> List[Dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, category
|
||||||
|
FROM skills
|
||||||
|
WHERE status IS NULL OR status = 'active'
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": int(r["id"]),
|
||||||
|
"name": str(r["name"] or "")[:80],
|
||||||
|
"category": str(r.get("category") or "")[:40],
|
||||||
|
}
|
||||||
|
for r in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_name_hint(cur, table: str, hint: str, *, extra_where: str = "") -> Optional[int]:
|
||||||
|
h = (hint or "").strip()
|
||||||
|
if len(h) < 2:
|
||||||
|
return None
|
||||||
|
q = h.lower()
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, name
|
||||||
|
FROM {table}
|
||||||
|
WHERE LOWER(name) LIKE %s {extra_where}
|
||||||
|
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
|
||||||
|
LENGTH(name) ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(f"%{q}%", q, f"{q}%"),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return int(row["id"]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_query_intent_catalog_ids(
|
||||||
|
cur,
|
||||||
|
parsed: PlanningQueryIntentParsed,
|
||||||
|
) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], List[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Mappt Text-Hints auf Katalog-IDs. Returns (focus, style, tt, tg, skills, resolved_skills_meta).
|
||||||
|
"""
|
||||||
|
focus: Dict[int, float] = {}
|
||||||
|
style: Dict[int, float] = {}
|
||||||
|
tt: Dict[int, float] = {}
|
||||||
|
tg: Dict[int, float] = {}
|
||||||
|
skills: Dict[int, float] = {}
|
||||||
|
resolved_skills: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for hint in parsed.focus_hints:
|
||||||
|
fid = _resolve_name_hint(cur, "focus_areas", hint)
|
||||||
|
if fid:
|
||||||
|
focus[fid] = max(focus.get(fid, 0.0), 0.9)
|
||||||
|
|
||||||
|
for hint in parsed.style_hints:
|
||||||
|
sid = _resolve_name_hint(cur, "style_directions", hint)
|
||||||
|
if sid:
|
||||||
|
style[sid] = max(style.get(sid, 0.0), 0.85)
|
||||||
|
|
||||||
|
for hint in parsed.training_type_hints:
|
||||||
|
tid = _resolve_name_hint(cur, "training_types", hint)
|
||||||
|
if tid:
|
||||||
|
tt[tid] = max(tt.get(tid, 0.0), 0.85)
|
||||||
|
|
||||||
|
for hint in parsed.target_group_hints:
|
||||||
|
gid = _resolve_name_hint(cur, "target_groups", hint)
|
||||||
|
if gid:
|
||||||
|
tg[gid] = max(tg.get(gid, 0.0), 0.85)
|
||||||
|
|
||||||
|
for sh in parsed.skill_hints[:8]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name FROM skills
|
||||||
|
WHERE (status IS NULL OR status = 'active')
|
||||||
|
AND LOWER(name) LIKE %s
|
||||||
|
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
|
||||||
|
LENGTH(name) ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(f"%{sh.name.lower()}%", sh.name.lower(), f"{sh.name.lower()}%"),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
sid = int(row["id"])
|
||||||
|
skills[sid] = max(skills.get(sid, 0.0), float(sh.weight))
|
||||||
|
resolved_skills.append({"skill_id": sid, "name": str(row["name"] or sh.name), "weight": skills[sid]})
|
||||||
|
|
||||||
|
return focus, style, tt, tg, skills, resolved_skills
|
||||||
|
|
||||||
|
|
||||||
|
def try_parse_planning_query_intent(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
heuristic_intent: str,
|
||||||
|
scenario_hint: str,
|
||||||
|
context_summary: Mapping[str, Any],
|
||||||
|
target_profile_summary: Mapping[str, Any],
|
||||||
|
) -> Tuple[Optional[PlanningQueryIntentParsed], bool]:
|
||||||
|
api_key, _ = normalize_openrouter_env()
|
||||||
|
if not api_key or not (query or "").strip():
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
variables = {
|
||||||
|
"search_query": (query or "").strip(),
|
||||||
|
"heuristic_intent": heuristic_intent or "",
|
||||||
|
"scenario_hint": scenario_hint or "",
|
||||||
|
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||||
|
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||||
|
"skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
|
||||||
|
"focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
|
||||||
|
"training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
|
||||||
|
"style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
|
||||||
|
"target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_intent", variables)
|
||||||
|
model = effective_openrouter_model_for_prompt_row(prow)
|
||||||
|
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||||
|
parsed = parse_planning_query_intent_response(raw)
|
||||||
|
return parsed, True
|
||||||
|
except AiPromptUnavailableError:
|
||||||
|
return None, False
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Planungs-Intent-LLM fehlgeschlagen: %s", exc)
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PlanningQueryIntentParsed",
|
||||||
|
"parse_planning_query_intent_response",
|
||||||
|
"resolve_query_intent_catalog_ids",
|
||||||
|
"try_parse_planning_query_intent",
|
||||||
|
]
|
||||||
|
|
@ -107,6 +107,7 @@ class PlanningTargetProfile:
|
||||||
target_group_ids: Dict[int, float] = field(default_factory=dict)
|
target_group_ids: Dict[int, float] = field(default_factory=dict)
|
||||||
skill_weights: Dict[int, float] = field(default_factory=dict)
|
skill_weights: Dict[int, float] = field(default_factory=dict)
|
||||||
skill_gap_weights: Dict[int, float] = field(default_factory=dict)
|
skill_gap_weights: Dict[int, float] = field(default_factory=dict)
|
||||||
|
skill_plan_weights: Dict[int, float] = field(default_factory=dict)
|
||||||
sources: List[str] = field(default_factory=list)
|
sources: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
def to_summary_dict(self, cur, limit_skills: int = 5) -> Dict[str, Any]:
|
def to_summary_dict(self, cur, limit_skills: int = 5) -> Dict[str, Any]:
|
||||||
|
|
@ -385,6 +386,7 @@ def build_planning_target_profile(
|
||||||
target_group_ids=_normalize_weight_map(tg) if tg else tg,
|
target_group_ids=_normalize_weight_map(tg) if tg else tg,
|
||||||
skill_weights=skill_target,
|
skill_weights=skill_target,
|
||||||
skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else skill_gap,
|
skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else skill_gap,
|
||||||
|
skill_plan_weights=skill_plan_norm,
|
||||||
sources=sources,
|
sources=sources,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -420,6 +422,8 @@ def score_exercise_against_target(
|
||||||
reasons.append("Fähigkeiten-Schwerpunkt passend (Profilmetrik)")
|
reasons.append("Fähigkeiten-Schwerpunkt passend (Profilmetrik)")
|
||||||
if gap_sim >= 0.25 and target.skill_gap_weights:
|
if gap_sim >= 0.25 and target.skill_gap_weights:
|
||||||
reasons.append("Deckt Skill-Lücke im bisherigen Plan")
|
reasons.append("Deckt Skill-Lücke im bisherigen Plan")
|
||||||
|
if "query_intent" in (target.sources or []):
|
||||||
|
reasons.append("Passt zur KI-interpretierten Suchanfrage")
|
||||||
|
|
||||||
# Intent-gewichtete Dimensionen (Summe = 1.0)
|
# Intent-gewichtete Dimensionen (Summe = 1.0)
|
||||||
if intent == INTENT_FREE_SEARCH:
|
if intent == INTENT_FREE_SEARCH:
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,14 @@ from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from tenant_context import TenantContext, library_content_visibility_sql
|
from tenant_context import TenantContext, library_content_visibility_sql
|
||||||
from planning_exercise_profiles import (
|
from planning_exercise_profiles import (
|
||||||
build_planning_target_profile,
|
|
||||||
load_exercise_match_profiles_bulk,
|
load_exercise_match_profiles_bulk,
|
||||||
score_exercise_against_target,
|
score_exercise_against_target,
|
||||||
)
|
)
|
||||||
from planning_exercise_llm_rank import try_llm_rerank_planning_hits
|
from planning_exercise_llm_rank import try_llm_rerank_planning_hits
|
||||||
|
from planning_exercise_target_pipeline import (
|
||||||
|
build_planning_target_with_query_pipeline,
|
||||||
|
compose_retrieval_phase,
|
||||||
|
)
|
||||||
|
|
||||||
# Planungs-Berechtigung + Sektionen (bestehende Implementierung)
|
# Planungs-Berechtigung + Sektionen (bestehende Implementierung)
|
||||||
from routers.training_planning import (
|
from routers.training_planning import (
|
||||||
|
|
@ -54,6 +57,7 @@ class PlanningExerciseSuggestRequest(BaseModel):
|
||||||
query: Optional[str] = ""
|
query: Optional[str] = ""
|
||||||
intent_hint: Optional[str] = None
|
intent_hint: Optional[str] = None
|
||||||
planned_exercise_ids: Optional[List[int]] = None
|
planned_exercise_ids: Optional[List[int]] = None
|
||||||
|
include_llm_intent: bool = True
|
||||||
include_llm_rank: bool = False
|
include_llm_rank: bool = False
|
||||||
limit: int = Field(default=20, ge=1, le=50)
|
limit: int = Field(default=20, ge=1, le=50)
|
||||||
exercise_kind_any: Optional[List[str]] = None
|
exercise_kind_any: Optional[List[str]] = None
|
||||||
|
|
@ -369,16 +373,30 @@ def suggest_planning_exercises(
|
||||||
pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body)
|
pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body)
|
||||||
pack = _apply_client_planned_override(cur, pack, body)
|
pack = _apply_client_planned_override(cur, pack, body)
|
||||||
query = _normalize_query(body.query)
|
query = _normalize_query(body.query)
|
||||||
intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
||||||
weights = _intent_weights(intent)
|
|
||||||
target_profile = build_planning_target_profile(
|
pipeline_context = {
|
||||||
|
"unit_title": pack.get("unit_title"),
|
||||||
|
"group_name": pack.get("group_name"),
|
||||||
|
"section_title": pack.get("section_title"),
|
||||||
|
"planned_count": len(pack.get("planned_exercise_ids") or []),
|
||||||
|
"anchor_title": pack.get("anchor_title"),
|
||||||
|
"anchor_exercise_id": pack.get("anchor_exercise_id"),
|
||||||
|
"progression_graph_id": pack.get("progression_graph_id"),
|
||||||
|
}
|
||||||
|
target_profile, intent, scenario_kind, query_intent_summary = build_planning_target_with_query_pipeline(
|
||||||
cur,
|
cur,
|
||||||
unit=pack["unit"],
|
unit=pack["unit"],
|
||||||
planned_exercise_ids=pack["planned_exercise_ids"],
|
planned_exercise_ids=pack["planned_exercise_ids"],
|
||||||
anchor_exercise_id=pack.get("anchor_exercise_id"),
|
anchor_exercise_id=pack.get("anchor_exercise_id"),
|
||||||
intent=intent,
|
query=query,
|
||||||
|
heuristic_intent=heuristic_intent,
|
||||||
|
include_llm_intent=body.include_llm_intent,
|
||||||
|
context_summary=pipeline_context,
|
||||||
)
|
)
|
||||||
|
weights = _intent_weights(intent)
|
||||||
target_profile_summary = target_profile.to_summary_dict(cur)
|
target_profile_summary = target_profile.to_summary_dict(cur)
|
||||||
|
query_intent_applied = bool(query_intent_summary.get("llm_applied"))
|
||||||
|
|
||||||
profile_id = tenant.profile_id
|
profile_id = tenant.profile_id
|
||||||
role = tenant.global_role
|
role = tenant.global_role
|
||||||
|
|
@ -540,7 +558,7 @@ def suggest_planning_exercises(
|
||||||
hits.sort(key=lambda h: (-h["score"], h.get("title") or ""))
|
hits.sort(key=lambda h: (-h["score"], h.get("title") or ""))
|
||||||
|
|
||||||
llm_applied = False
|
llm_applied = False
|
||||||
retrieval_phase = "profile_v1"
|
retrieval_phase = compose_retrieval_phase(query_intent=query_intent_applied, llm_rank=False)
|
||||||
if body.include_llm_rank:
|
if body.include_llm_rank:
|
||||||
pre_limit = max(int(body.limit), _LLM_RERANK_PRE_LIMIT)
|
pre_limit = max(int(body.limit), _LLM_RERANK_PRE_LIMIT)
|
||||||
pool_hits = hits[:pre_limit]
|
pool_hits = hits[:pre_limit]
|
||||||
|
|
@ -562,7 +580,10 @@ def suggest_planning_exercises(
|
||||||
limit=int(body.limit),
|
limit=int(body.limit),
|
||||||
)
|
)
|
||||||
if llm_applied:
|
if llm_applied:
|
||||||
retrieval_phase = "profile_v1+llm_rank"
|
retrieval_phase = compose_retrieval_phase(
|
||||||
|
query_intent=query_intent_applied,
|
||||||
|
llm_rank=True,
|
||||||
|
)
|
||||||
tail = hits[pre_limit:]
|
tail = hits[pre_limit:]
|
||||||
hits = pool_hits + tail
|
hits = pool_hits + tail
|
||||||
else:
|
else:
|
||||||
|
|
@ -585,9 +606,12 @@ def suggest_planning_exercises(
|
||||||
return {
|
return {
|
||||||
"context_summary": context_summary,
|
"context_summary": context_summary,
|
||||||
"target_profile_summary": target_profile_summary,
|
"target_profile_summary": target_profile_summary,
|
||||||
|
"scenario_kind": scenario_kind,
|
||||||
|
"query_intent_summary": query_intent_summary,
|
||||||
"retrieval_phase": retrieval_phase,
|
"retrieval_phase": retrieval_phase,
|
||||||
"llm_rank_applied": llm_applied,
|
"llm_rank_applied": llm_applied,
|
||||||
"intent_resolved": intent,
|
"intent_resolved": intent,
|
||||||
|
"intent_heuristic": heuristic_intent,
|
||||||
"query_normalized": query or None,
|
"query_normalized": query or None,
|
||||||
"hits": hits,
|
"hits": hits,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
284
backend/planning_exercise_target_pipeline.py
Normal file
284
backend/planning_exercise_target_pipeline.py
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
"""
|
||||||
|
Szenario-Routing und Erwartungsprofil-Pipeline für Planungs-Übungssuche (P1).
|
||||||
|
|
||||||
|
Ablauf:
|
||||||
|
1. Heuristik: Intent + Szenario-Klasse aus Query/Kontext
|
||||||
|
2. Optional LLM (planning_exercise_search_intent) bei komplexen Anfragen
|
||||||
|
3. Deterministisches Basis-Profil (Rahmen, Plan, Anker)
|
||||||
|
4. Query-Overlay mergen → PlanningTargetProfile für Vorselektion
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||||
|
|
||||||
|
from planning_exercise_intent import (
|
||||||
|
PlanningQueryIntentParsed,
|
||||||
|
resolve_query_intent_catalog_ids,
|
||||||
|
try_parse_planning_query_intent,
|
||||||
|
)
|
||||||
|
from planning_exercise_profiles import (
|
||||||
|
PlanningTargetProfile,
|
||||||
|
_merge_weight_maps,
|
||||||
|
_normalize_weight_map,
|
||||||
|
build_planning_target_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCENARIO_PRESET_NEXT = "preset_next"
|
||||||
|
SCENARIO_PROGRESSION = "progression"
|
||||||
|
SCENARIO_DEEPEN = "deepen"
|
||||||
|
SCENARIO_CONTINUE_PLAN = "continue_plan"
|
||||||
|
SCENARIO_ADDITIVE = "additive_constraint"
|
||||||
|
SCENARIO_FREE_SEARCH = "free_search"
|
||||||
|
|
||||||
|
_SIMPLE_PRESET_PATTERNS = (
|
||||||
|
r"^(schlage?\s+(mir\s+)?(die\s+)?(n[aä]chste|naechste)\s+(sinnvolle\s+)?(übung|uebung)\s*(vor)?\.?)$",
|
||||||
|
r"^(n[aä]chste|naechste)\s+(übung|uebung)\s*(vorschlag|vorschlagen|empfehl\w*)?\.?$",
|
||||||
|
r"^(vorschlag|vorschlagen|empfehl\w*)\s*(für|fuer)?\s*(die\s+)?(n[aä]chste|naechste)?\s*(übung|uebung)?\.?$",
|
||||||
|
r"^n[aä]chste\s+übung$",
|
||||||
|
r"^n[aä]chste\s+uebung$",
|
||||||
|
)
|
||||||
|
|
||||||
|
_ADDITIVE_MARKERS = (
|
||||||
|
"zusätzlich",
|
||||||
|
"zusaetzlich",
|
||||||
|
"auch ",
|
||||||
|
" außerdem",
|
||||||
|
" ausserdem",
|
||||||
|
" dazu",
|
||||||
|
" extra",
|
||||||
|
" mehr ",
|
||||||
|
" und dabei",
|
||||||
|
" sowie ",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_query(q: Optional[str]) -> str:
|
||||||
|
return re.sub(r"\s+", " ", (q or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def is_simple_preset_query(query: Optional[str]) -> bool:
|
||||||
|
q = _normalize_query(query).lower()
|
||||||
|
if not q:
|
||||||
|
return True
|
||||||
|
for pat in _SIMPLE_PRESET_PATTERNS:
|
||||||
|
if re.match(pat, q, flags=re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def classify_planning_scenario(
|
||||||
|
query: Optional[str],
|
||||||
|
heuristic_intent: str,
|
||||||
|
) -> str:
|
||||||
|
q = _normalize_query(query).lower()
|
||||||
|
if not q or is_simple_preset_query(q):
|
||||||
|
return SCENARIO_PRESET_NEXT
|
||||||
|
if heuristic_intent == "progression_next":
|
||||||
|
return SCENARIO_PROGRESSION
|
||||||
|
if heuristic_intent == "deepen_exercise":
|
||||||
|
return SCENARIO_DEEPEN
|
||||||
|
if any(m in f" {q} " for m in _ADDITIVE_MARKERS):
|
||||||
|
return SCENARIO_ADDITIVE
|
||||||
|
if heuristic_intent == "continue_plan_goal":
|
||||||
|
return SCENARIO_CONTINUE_PLAN
|
||||||
|
if heuristic_intent == "free_search":
|
||||||
|
return SCENARIO_FREE_SEARCH
|
||||||
|
if heuristic_intent == "suggest_next":
|
||||||
|
return SCENARIO_CONTINUE_PLAN
|
||||||
|
return SCENARIO_FREE_SEARCH
|
||||||
|
|
||||||
|
|
||||||
|
def should_run_llm_intent_pipeline(
|
||||||
|
query: Optional[str],
|
||||||
|
scenario: str,
|
||||||
|
*,
|
||||||
|
include_llm_intent: bool,
|
||||||
|
) -> bool:
|
||||||
|
if not include_llm_intent:
|
||||||
|
return False
|
||||||
|
if scenario == SCENARIO_PRESET_NEXT:
|
||||||
|
return False
|
||||||
|
return bool(_normalize_query(query))
|
||||||
|
|
||||||
|
|
||||||
|
def _recalculate_skill_gap(target: PlanningTargetProfile) -> PlanningTargetProfile:
|
||||||
|
skill_target = _normalize_weight_map(dict(target.skill_weights))
|
||||||
|
skill_plan_norm = _normalize_weight_map(dict(target.skill_plan_weights))
|
||||||
|
skill_gap: Dict[int, float] = {}
|
||||||
|
for sid, tw in skill_target.items():
|
||||||
|
pw = skill_plan_norm.get(sid, 0.0)
|
||||||
|
gap = tw - pw * 0.85
|
||||||
|
if gap > 0.08:
|
||||||
|
skill_gap[sid] = gap
|
||||||
|
sources = list(target.sources)
|
||||||
|
if skill_gap and "skill_gap_vs_plan" not in sources:
|
||||||
|
sources.append("skill_gap_vs_plan")
|
||||||
|
elif not skill_gap:
|
||||||
|
sources = [s for s in sources if s != "skill_gap_vs_plan"]
|
||||||
|
return PlanningTargetProfile(
|
||||||
|
focus_area_ids=target.focus_area_ids,
|
||||||
|
style_direction_ids=target.style_direction_ids,
|
||||||
|
training_type_ids=target.training_type_ids,
|
||||||
|
target_group_ids=target.target_group_ids,
|
||||||
|
skill_weights=skill_target,
|
||||||
|
skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else {},
|
||||||
|
skill_plan_weights=target.skill_plan_weights,
|
||||||
|
sources=sources,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_query_overlay_into_target(
|
||||||
|
base: PlanningTargetProfile,
|
||||||
|
*,
|
||||||
|
focus: Dict[int, float],
|
||||||
|
style: Dict[int, float],
|
||||||
|
tt: Dict[int, float],
|
||||||
|
tg: Dict[int, float],
|
||||||
|
skills: Dict[int, float],
|
||||||
|
emphasis: str = "additive",
|
||||||
|
scenario: str,
|
||||||
|
) -> PlanningTargetProfile:
|
||||||
|
sources = list(base.sources)
|
||||||
|
if "query_intent" not in sources:
|
||||||
|
sources.append("query_intent")
|
||||||
|
|
||||||
|
if emphasis == "replace" or scenario == SCENARIO_FREE_SEARCH:
|
||||||
|
skill_w = _merge_weight_maps({}, skills, scale=1.0)
|
||||||
|
if skills:
|
||||||
|
skill_w = _normalize_weight_map(_merge_weight_maps(base.skill_weights, skills, scale=0.55))
|
||||||
|
if emphasis == "replace":
|
||||||
|
skill_w = _normalize_weight_map(skills)
|
||||||
|
focus_w = _merge_weight_maps(base.focus_area_ids, focus, scale=0.5 if emphasis == "replace" else 0.85)
|
||||||
|
style_w = _merge_weight_maps(base.style_direction_ids, style, scale=0.5)
|
||||||
|
tt_w = _merge_weight_maps(base.training_type_ids, tt, scale=0.5)
|
||||||
|
tg_w = _merge_weight_maps(base.target_group_ids, tg, scale=0.5)
|
||||||
|
else:
|
||||||
|
skill_scale = 1.0 if scenario == SCENARIO_ADDITIVE else 0.85
|
||||||
|
skill_w = _merge_weight_maps(base.skill_weights, skills, scale=skill_scale)
|
||||||
|
focus_w = _merge_weight_maps(base.focus_area_ids, focus, scale=0.9)
|
||||||
|
style_w = _merge_weight_maps(base.style_direction_ids, style, scale=0.75)
|
||||||
|
tt_w = _merge_weight_maps(base.training_type_ids, tt, scale=0.75)
|
||||||
|
tg_w = _merge_weight_maps(base.target_group_ids, tg, scale=0.75)
|
||||||
|
|
||||||
|
out = PlanningTargetProfile(
|
||||||
|
focus_area_ids=_normalize_weight_map(focus_w) if focus_w else focus_w,
|
||||||
|
style_direction_ids=_normalize_weight_map(style_w) if style_w else style_w,
|
||||||
|
training_type_ids=_normalize_weight_map(tt_w) if tt_w else tt_w,
|
||||||
|
target_group_ids=_normalize_weight_map(tg_w) if tg_w else tg_w,
|
||||||
|
skill_weights=_normalize_weight_map(skill_w) if skill_w else skill_w,
|
||||||
|
skill_gap_weights=dict(base.skill_gap_weights),
|
||||||
|
skill_plan_weights=dict(base.skill_plan_weights),
|
||||||
|
sources=sources,
|
||||||
|
)
|
||||||
|
return _recalculate_skill_gap(out)
|
||||||
|
|
||||||
|
|
||||||
|
def build_planning_target_with_query_pipeline(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
unit: Dict[str, Any],
|
||||||
|
planned_exercise_ids: List[int],
|
||||||
|
anchor_exercise_id: Optional[int],
|
||||||
|
query: Optional[str],
|
||||||
|
heuristic_intent: str,
|
||||||
|
include_llm_intent: bool,
|
||||||
|
context_summary: Mapping[str, Any],
|
||||||
|
) -> Tuple[PlanningTargetProfile, str, str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Returns: target_profile, resolved_intent, scenario_kind, query_intent_summary dict
|
||||||
|
"""
|
||||||
|
scenario = classify_planning_scenario(query, heuristic_intent)
|
||||||
|
resolved_intent = heuristic_intent
|
||||||
|
llm_applied = False
|
||||||
|
parsed: Optional[PlanningQueryIntentParsed] = None
|
||||||
|
resolved_skills: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
base = build_planning_target_profile(
|
||||||
|
cur,
|
||||||
|
unit=unit,
|
||||||
|
planned_exercise_ids=planned_exercise_ids,
|
||||||
|
anchor_exercise_id=anchor_exercise_id,
|
||||||
|
intent=heuristic_intent,
|
||||||
|
)
|
||||||
|
base_summary = base.to_summary_dict(cur)
|
||||||
|
|
||||||
|
if should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent):
|
||||||
|
parsed, llm_applied = try_parse_planning_query_intent(
|
||||||
|
cur,
|
||||||
|
query=_normalize_query(query),
|
||||||
|
heuristic_intent=heuristic_intent,
|
||||||
|
scenario_hint=scenario,
|
||||||
|
context_summary=context_summary,
|
||||||
|
target_profile_summary=base_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
target = base
|
||||||
|
if parsed and llm_applied:
|
||||||
|
if parsed.intent in {
|
||||||
|
"suggest_next",
|
||||||
|
"progression_next",
|
||||||
|
"deepen_exercise",
|
||||||
|
"continue_plan_goal",
|
||||||
|
"free_search",
|
||||||
|
}:
|
||||||
|
resolved_intent = parsed.intent
|
||||||
|
if parsed.scenario in VALID_SCENARIOS_SET:
|
||||||
|
scenario = parsed.scenario
|
||||||
|
|
||||||
|
focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed)
|
||||||
|
if focus or style or tt or tg or skills:
|
||||||
|
target = merge_query_overlay_into_target(
|
||||||
|
base,
|
||||||
|
focus=focus,
|
||||||
|
style=style,
|
||||||
|
tt=tt,
|
||||||
|
tg=tg,
|
||||||
|
skills=skills,
|
||||||
|
emphasis=parsed.emphasis,
|
||||||
|
scenario=scenario,
|
||||||
|
)
|
||||||
|
|
||||||
|
query_intent_summary: Dict[str, Any] = {
|
||||||
|
"scenario": scenario,
|
||||||
|
"intent": resolved_intent,
|
||||||
|
"heuristic_intent": heuristic_intent,
|
||||||
|
"llm_applied": llm_applied,
|
||||||
|
"emphasis": parsed.emphasis if parsed else None,
|
||||||
|
"rationale": (parsed.rationale if parsed else None),
|
||||||
|
"skill_hints_resolved": resolved_skills,
|
||||||
|
"requires_partner": parsed.requires_partner if parsed else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return target, resolved_intent, scenario, query_intent_summary
|
||||||
|
|
||||||
|
|
||||||
|
VALID_SCENARIOS_SET = {
|
||||||
|
SCENARIO_PRESET_NEXT,
|
||||||
|
SCENARIO_PROGRESSION,
|
||||||
|
SCENARIO_DEEPEN,
|
||||||
|
SCENARIO_CONTINUE_PLAN,
|
||||||
|
SCENARIO_ADDITIVE,
|
||||||
|
SCENARIO_FREE_SEARCH,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compose_retrieval_phase(*, query_intent: bool, llm_rank: bool) -> str:
|
||||||
|
parts = ["profile_v1"]
|
||||||
|
if query_intent:
|
||||||
|
parts.append("query_intent")
|
||||||
|
if llm_rank:
|
||||||
|
parts.append("llm_rank")
|
||||||
|
return "+".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SCENARIO_ADDITIVE",
|
||||||
|
"SCENARIO_PRESET_NEXT",
|
||||||
|
"build_planning_target_with_query_pipeline",
|
||||||
|
"classify_planning_scenario",
|
||||||
|
"compose_retrieval_phase",
|
||||||
|
"is_simple_preset_query",
|
||||||
|
"merge_query_overlay_into_target",
|
||||||
|
"should_run_llm_intent_pipeline",
|
||||||
|
]
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
"""Tests für Planungs-Übungssuche (Intent, LLM-Rerank-Parser)."""
|
"""Tests Planungs-Übungssuche: Intent, Szenario-Pipeline, LLM-Parser."""
|
||||||
from planning_exercise_suggest import resolve_planning_exercise_intent
|
from planning_exercise_suggest import resolve_planning_exercise_intent
|
||||||
|
from planning_exercise_intent import parse_planning_query_intent_response
|
||||||
from planning_exercise_llm_rank import parse_planning_exercise_rank_response
|
from planning_exercise_llm_rank import parse_planning_exercise_rank_response
|
||||||
|
from planning_exercise_target_pipeline import (
|
||||||
|
SCENARIO_ADDITIVE,
|
||||||
|
SCENARIO_PRESET_NEXT,
|
||||||
|
classify_planning_scenario,
|
||||||
|
compose_retrieval_phase,
|
||||||
|
is_simple_preset_query,
|
||||||
|
should_run_llm_intent_pipeline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_planning_exercise_intent_defaults():
|
def test_resolve_planning_exercise_intent_defaults():
|
||||||
|
|
@ -14,6 +23,43 @@ def test_resolve_planning_exercise_intent_keywords():
|
||||||
assert resolve_planning_exercise_intent("progression graph", None) == "progression_next"
|
assert resolve_planning_exercise_intent("progression graph", None) == "progression_next"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_planning_scenario_preset():
|
||||||
|
assert is_simple_preset_query("Schlage mir die nächste Übung vor")
|
||||||
|
assert classify_planning_scenario("", "suggest_next") == SCENARIO_PRESET_NEXT
|
||||||
|
assert classify_planning_scenario("nächste übung", "suggest_next") == SCENARIO_PRESET_NEXT
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_planning_scenario_additive():
|
||||||
|
q = "Baut auf der Planung auf und trainiert zusätzlich Schnellkraft"
|
||||||
|
assert classify_planning_scenario(q, "continue_plan_goal") == SCENARIO_ADDITIVE
|
||||||
|
assert should_run_llm_intent_pipeline(q, SCENARIO_ADDITIVE, include_llm_intent=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_skip_llm_for_preset():
|
||||||
|
assert not should_run_llm_intent_pipeline("", SCENARIO_PRESET_NEXT, include_llm_intent=True)
|
||||||
|
assert not should_run_llm_intent_pipeline(
|
||||||
|
"nächste übung",
|
||||||
|
SCENARIO_PRESET_NEXT,
|
||||||
|
include_llm_intent=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_retrieval_phase():
|
||||||
|
assert compose_retrieval_phase(query_intent=False, llm_rank=False) == "profile_v1"
|
||||||
|
assert compose_retrieval_phase(query_intent=True, llm_rank=True) == "profile_v1+query_intent+llm_rank"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_planning_query_intent_response():
|
||||||
|
parsed = parse_planning_query_intent_response(
|
||||||
|
'{"intent":"continue_plan_goal","scenario":"additive_constraint",'
|
||||||
|
'"skill_hints":[{"name":"Schnellkraft","weight":1}],"emphasis":"additive",'
|
||||||
|
'"rationale":"Zusatz Schnellkraft"}'
|
||||||
|
)
|
||||||
|
assert parsed.intent == "continue_plan_goal"
|
||||||
|
assert parsed.scenario == "additive_constraint"
|
||||||
|
assert parsed.skill_hints[0].name == "Schnellkraft"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_planning_exercise_rank_response_filters_ids():
|
def test_parse_planning_exercise_rank_response_filters_ids():
|
||||||
allowed = {10, 20, 30}
|
allowed = {10, 20, 30}
|
||||||
ranked, reasons = parse_planning_exercise_rank_response(
|
ranked, reasons = parse_planning_exercise_rank_response(
|
||||||
|
|
@ -23,12 +69,3 @@ def test_parse_planning_exercise_rank_response_filters_ids():
|
||||||
assert ranked == [20, 10]
|
assert ranked == [20, 10]
|
||||||
assert reasons[20] == "Passt gut"
|
assert reasons[20] == "Passt gut"
|
||||||
assert 999 not in reasons
|
assert 999 not in reasons
|
||||||
|
|
||||||
|
|
||||||
def test_parse_planning_exercise_rank_response_reasons_by_id_alias():
|
|
||||||
ranked, reasons = parse_planning_exercise_rank_response(
|
|
||||||
'{"ranked_ids":[5],"reasons_by_id":{"5":"Skill-Lücke"}}',
|
|
||||||
{5},
|
|
||||||
)
|
|
||||||
assert ranked == [5]
|
|
||||||
assert reasons[5] == "Skill-Lücke"
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.170"
|
APP_VERSION = "0.8.171"
|
||||||
BUILD_DATE = "2026-05-22"
|
BUILD_DATE = "2026-05-22"
|
||||||
DB_SCHEMA_VERSION = "20260531072"
|
DB_SCHEMA_VERSION = "20260531073"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||||
|
|
@ -27,8 +27,8 @@ MODULE_VERSIONS = {
|
||||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.36.0", # Planungs-KI P2: LLM-Rerank + Client planned_exercise_ids
|
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
||||||
"planning_exercise_suggest": "0.3.0", # include_llm_rank, planned_exercise_ids Override
|
"planning_exercise_suggest": "0.4.0", # include_llm_intent, scenario_kind, query_intent_summary
|
||||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||||
|
|
@ -43,6 +43,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.171",
|
||||||
|
"date": "2026-05-22",
|
||||||
|
"changes": [
|
||||||
|
"Planungs-KI P1: Szenario-Pipeline (preset/progression/additive/…) + LLM Intent planning_exercise_search_intent → Erwartungsprofil-Overlay.",
|
||||||
|
"API: scenario_kind, query_intent_summary, include_llm_intent; Migration 073.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.170",
|
"version": "0.8.170",
|
||||||
"date": "2026-05-22",
|
"date": "2026-05-22",
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,9 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
||||||
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
||||||
|
|
||||||
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.170**)
|
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.171**)
|
||||||
|
|
||||||
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4.
|
- **Planungs-Übungssuche (P1):** Szenario-Pipeline + **LLM Query-Intent** (`planning_exercise_search_intent`) → Erwartungsprofil-Overlay; danach Hybrid + optional LLM-Rerank — `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §16.
|
||||||
- **Planungs-Übungssuche (P2):** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` — Hybrid + Profil-Score + optional **LLM-Rerank** (`include_llm_rank`, Prompt `planning_exercise_search_rank`); Client **`planned_exercise_ids`**; **`POST /api/planning/exercise-suggest`**; **`ExercisePickerModal`** + **`planningContext`** aus **`TrainingUnitEditPage`**.
|
|
||||||
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
|
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
|
||||||
- **Kontext / Job:** **`ai_prompt_context`** (Titel, Ziel, Durchführung, Vorbereitung, Trainer-Hinweise, Fokus); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**; **`ai_prompt_runtime`**; **`exercise_ai`** — OpenRouter
|
- **Kontext / Job:** **`ai_prompt_context`** (Titel, Ziel, Durchführung, Vorbereitung, Trainer-Hinweise, Fokus); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**; **`ai_prompt_runtime`**; **`exercise_ai`** — OpenRouter
|
||||||
- **DB:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`**
|
- **DB:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`**
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ export default function ExercisePickerModal({
|
||||||
const [planningContextSummary, setPlanningContextSummary] = useState(null)
|
const [planningContextSummary, setPlanningContextSummary] = useState(null)
|
||||||
const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null)
|
const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null)
|
||||||
const [planningLlmRankApplied, setPlanningLlmRankApplied] = useState(false)
|
const [planningLlmRankApplied, setPlanningLlmRankApplied] = useState(false)
|
||||||
|
const [planningQueryIntentSummary, setPlanningQueryIntentSummary] = useState(null)
|
||||||
const [planningIntentResolved, setPlanningIntentResolved] = useState(null)
|
const [planningIntentResolved, setPlanningIntentResolved] = useState(null)
|
||||||
const pickerScrollRef = useRef(null)
|
const pickerScrollRef = useRef(null)
|
||||||
|
|
||||||
|
|
@ -157,6 +158,7 @@ export default function ExercisePickerModal({
|
||||||
setPlanningContextSummary(null)
|
setPlanningContextSummary(null)
|
||||||
setPlanningTargetProfileSummary(null)
|
setPlanningTargetProfileSummary(null)
|
||||||
setPlanningLlmRankApplied(false)
|
setPlanningLlmRankApplied(false)
|
||||||
|
setPlanningQueryIntentSummary(null)
|
||||||
setPlanningIntentResolved(null)
|
setPlanningIntentResolved(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -277,6 +279,7 @@ export default function ExercisePickerModal({
|
||||||
Array.isArray(planningContext.plannedExerciseIds) && planningContext.plannedExerciseIds.length > 0
|
Array.isArray(planningContext.plannedExerciseIds) && planningContext.plannedExerciseIds.length > 0
|
||||||
? planningContext.plannedExerciseIds.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0)
|
? planningContext.plannedExerciseIds.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
include_llm_intent: Boolean(query),
|
||||||
include_llm_rank: true,
|
include_llm_rank: true,
|
||||||
query,
|
query,
|
||||||
intent_hint: planningContext.intentHint || null,
|
intent_hint: planningContext.intentHint || null,
|
||||||
|
|
@ -287,6 +290,7 @@ export default function ExercisePickerModal({
|
||||||
setPlanningContextSummary(res?.context_summary || null)
|
setPlanningContextSummary(res?.context_summary || null)
|
||||||
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
|
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
|
||||||
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
|
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
|
||||||
|
setPlanningQueryIntentSummary(res?.query_intent_summary || null)
|
||||||
setPlanningIntentResolved(res?.intent_resolved || null)
|
setPlanningIntentResolved(res?.intent_resolved || null)
|
||||||
const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({
|
const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({
|
||||||
id: h.id,
|
id: h.id,
|
||||||
|
|
@ -303,6 +307,7 @@ export default function ExercisePickerModal({
|
||||||
setPlanningContextSummary(null)
|
setPlanningContextSummary(null)
|
||||||
setPlanningTargetProfileSummary(null)
|
setPlanningTargetProfileSummary(null)
|
||||||
setPlanningLlmRankApplied(false)
|
setPlanningLlmRankApplied(false)
|
||||||
|
setPlanningQueryIntentSummary(null)
|
||||||
setPlanningIntentResolved(null)
|
setPlanningIntentResolved(null)
|
||||||
const batch = await api.listExercises({
|
const batch = await api.listExercises({
|
||||||
...queryBase,
|
...queryBase,
|
||||||
|
|
@ -322,6 +327,7 @@ export default function ExercisePickerModal({
|
||||||
setPlanningContextSummary(null)
|
setPlanningContextSummary(null)
|
||||||
setPlanningTargetProfileSummary(null)
|
setPlanningTargetProfileSummary(null)
|
||||||
setPlanningLlmRankApplied(false)
|
setPlanningLlmRankApplied(false)
|
||||||
|
setPlanningQueryIntentSummary(null)
|
||||||
setPlanningIntentResolved(null)
|
setPlanningIntentResolved(null)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
@ -545,10 +551,19 @@ export default function ExercisePickerModal({
|
||||||
Skill-Lücke zum bisherigen Plan berücksichtigt
|
Skill-Lücke zum bisherigen Plan berücksichtigt
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{planningQueryIntentSummary?.rationale ? (
|
||||||
|
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text2)' }}>
|
||||||
|
{planningQueryIntentSummary.rationale}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{planningIntentResolved ? (
|
{planningIntentResolved ? (
|
||||||
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
||||||
Modus: {planningIntentResolved.replace(/_/g, ' ')}
|
Modus: {planningIntentResolved.replace(/_/g, ' ')}
|
||||||
|
{planningQueryIntentSummary?.scenario
|
||||||
|
? ` · ${String(planningQueryIntentSummary.scenario).replace(/_/g, ' ')}`
|
||||||
|
: null}
|
||||||
{planningLlmRankApplied ? ' · KI-Ranking aktiv' : null}
|
{planningLlmRankApplied ? ' · KI-Ranking aktiv' : null}
|
||||||
|
{planningQueryIntentSummary?.llm_applied ? ' · KI-Intent aktiv' : null}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user