Enhance Path Evaluation and Slot Management Features
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
- Introduced `_parse_slot_refs_from_text` to extract and convert slot references from text, improving the handling of user input in path evaluations. - Updated `_problematic_slots_from_path_qa` to utilize the new parsing function, enhancing the identification of problematic slots based on various hints and issues. - Enhanced `ProgressionGraphEditor` and `ProgressionOptimizeCompareModal` to better display identified problem slots and their associated reasons, improving user feedback during evaluations. - Added tests for new parsing functionality and its integration with existing slot management processes, ensuring robustness in slot reference handling.
This commit is contained in:
parent
3468b2066e
commit
e9bf5bd1a5
|
|
@ -6,6 +6,7 @@ planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
@ -149,6 +150,7 @@ class ProgressionPathSuggestRequest(BaseModel):
|
||||||
baseline_quality_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
baseline_quality_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
||||||
include_incremental_diff_scoring: bool = False
|
include_incremental_diff_scoring: bool = False
|
||||||
unified_slot_review: bool = False
|
unified_slot_review: bool = False
|
||||||
|
baseline_path_qa_snapshot: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_planning_catalog_context(
|
def _resolve_planning_catalog_context(
|
||||||
|
|
@ -1165,6 +1167,7 @@ def _match_roadmap_slot(
|
||||||
anchor_variant_id: Optional[int],
|
anchor_variant_id: Optional[int],
|
||||||
used: Set[int],
|
used: Set[int],
|
||||||
slot_priority_exercise_id: Optional[int] = None,
|
slot_priority_exercise_id: Optional[int] = None,
|
||||||
|
skip_post_match_gate: bool = False,
|
||||||
) -> Tuple[Optional[Dict[str, Any]], Optional[StageSpecArtifact]]:
|
) -> Tuple[Optional[Dict[str, Any]], Optional[StageSpecArtifact]]:
|
||||||
"""Einzelnen Roadmap-Slot matchen (Initial-Build und Auto-Rematch)."""
|
"""Einzelnen Roadmap-Slot matchen (Initial-Build und Auto-Rematch)."""
|
||||||
major_by_index: Dict[int, MajorStep] = {}
|
major_by_index: Dict[int, MajorStep] = {}
|
||||||
|
|
@ -1331,11 +1334,15 @@ def _match_roadmap_slot(
|
||||||
else:
|
else:
|
||||||
step["slot_status"] = "matched"
|
step["slot_status"] = "matched"
|
||||||
step["roadmap_match_source"] = "stage_spec"
|
step["roadmap_match_source"] = "stage_spec"
|
||||||
if step.get("roadmap_match_source") != "slot_best_match" and not _roadmap_step_passes_post_match_gate(
|
if (
|
||||||
cur,
|
not skip_post_match_gate
|
||||||
step,
|
and step.get("roadmap_match_source") != "slot_best_match"
|
||||||
goal_query=goal_query,
|
and not _roadmap_step_passes_post_match_gate(
|
||||||
semantic_brief=semantic_brief,
|
cur,
|
||||||
|
step,
|
||||||
|
goal_query=goal_query,
|
||||||
|
semantic_brief=semantic_brief,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
return None, stage_spec
|
return None, stage_spec
|
||||||
return step, None
|
return step, None
|
||||||
|
|
@ -2478,6 +2485,21 @@ def _resolve_hint_major_index(
|
||||||
return pos if pos >= 0 else None
|
return pos if pos >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_slot_refs_from_text(text: str) -> Set[int]:
|
||||||
|
"""„Schritt 8“ / „Slot 8“ / „Stufe 8“ → 0-basierter major_step_index (7)."""
|
||||||
|
found: Set[int] = set()
|
||||||
|
if not text:
|
||||||
|
return found
|
||||||
|
for match in re.finditer(r"(?:schritt|slot|stufe)\s*(\d+)", text.lower()):
|
||||||
|
try:
|
||||||
|
n = int(match.group(1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if n >= 1:
|
||||||
|
found.add(n - 1)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def _problematic_slots_from_path_qa(
|
def _problematic_slots_from_path_qa(
|
||||||
baseline_qa: Optional[Mapping[str, Any]],
|
baseline_qa: Optional[Mapping[str, Any]],
|
||||||
baseline_steps: Sequence[Mapping[str, Any]],
|
baseline_steps: Sequence[Mapping[str, Any]],
|
||||||
|
|
@ -2504,13 +2526,32 @@ def _problematic_slots_from_path_qa(
|
||||||
if not isinstance(hint, dict):
|
if not isinstance(hint, dict):
|
||||||
continue
|
continue
|
||||||
action = str(hint.get("action") or "").strip().lower()
|
action = str(hint.get("action") or "").strip().lower()
|
||||||
if action in ("review_roadmap", "refine_stage_spec"):
|
if action == "review_roadmap":
|
||||||
continue
|
continue
|
||||||
midx = _resolve_hint_major_index(hint, stage_specs)
|
midx = _resolve_hint_major_index(hint, stage_specs)
|
||||||
|
if midx is None:
|
||||||
|
title = str(hint.get("title") or "")
|
||||||
|
for ref in _parse_slot_refs_from_text(
|
||||||
|
" ".join(
|
||||||
|
str(hint.get(k) or "")
|
||||||
|
for k in ("reason", "issue", "title", "roadmap_learning_goal")
|
||||||
|
)
|
||||||
|
):
|
||||||
|
midx = ref
|
||||||
|
break
|
||||||
|
if title:
|
||||||
|
for step in baseline_steps or []:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
st = str(step.get("title") or "").strip()
|
||||||
|
smidx = step.get("roadmap_major_step_index")
|
||||||
|
if st and title.lower() in st.lower() and smidx is not None:
|
||||||
|
midx = int(smidx)
|
||||||
|
break
|
||||||
if midx is None:
|
if midx is None:
|
||||||
continue
|
continue
|
||||||
_add(
|
_add(
|
||||||
midx,
|
int(midx),
|
||||||
str(
|
str(
|
||||||
hint.get("reason")
|
hint.get("reason")
|
||||||
or hint.get("issue")
|
or hint.get("issue")
|
||||||
|
|
@ -2519,6 +2560,18 @@ def _problematic_slots_from_path_qa(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
llm_text_parts: List[str] = []
|
||||||
|
for key in ("topic_coverage",):
|
||||||
|
raw = (baseline_qa or {}).get(key)
|
||||||
|
if raw:
|
||||||
|
llm_text_parts.append(str(raw))
|
||||||
|
for key in ("issues", "recommendations", "sequence_notes"):
|
||||||
|
for raw in (baseline_qa or {}).get(key) or []:
|
||||||
|
llm_text_parts.append(str(raw or ""))
|
||||||
|
combined = "\n".join(llm_text_parts)
|
||||||
|
for midx in _parse_slot_refs_from_text(combined):
|
||||||
|
_add(midx, "In Pfad-Bewertung als Schachstelle genannt")
|
||||||
|
|
||||||
for raw in (baseline_qa or {}).get("issues") or []:
|
for raw in (baseline_qa or {}).get("issues") or []:
|
||||||
text = str(raw or "").strip()
|
text = str(raw or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
|
|
@ -2535,7 +2588,8 @@ def _problematic_slots_from_path_qa(
|
||||||
continue
|
continue
|
||||||
title = str(step.get("title") or "").strip()
|
title = str(step.get("title") or "").strip()
|
||||||
if (
|
if (
|
||||||
f"slot {slot_no}" in text.lower()
|
f"schritt {slot_no}" in text.lower()
|
||||||
|
or f"slot {slot_no}" in text.lower()
|
||||||
or f"stufe {slot_no}" in text.lower()
|
or f"stufe {slot_no}" in text.lower()
|
||||||
or (title and title.lower() in text.lower())
|
or (title and title.lower() in text.lower())
|
||||||
):
|
):
|
||||||
|
|
@ -2896,6 +2950,7 @@ def _roadmap_slot_library_candidates(
|
||||||
used: Set[int],
|
used: Set[int],
|
||||||
exclude_exercise_id: Optional[int] = None,
|
exclude_exercise_id: Optional[int] = None,
|
||||||
max_candidates: int = 5,
|
max_candidates: int = 5,
|
||||||
|
skip_post_match_gate: bool = False,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Mehrere Bibliotheks-Kandidaten je Slot (beste zuerst, aktuelle optional ausgeschlossen)."""
|
"""Mehrere Bibliotheks-Kandidaten je Slot (beste zuerst, aktuelle optional ausgeschlossen)."""
|
||||||
pick_used = set(used)
|
pick_used = set(used)
|
||||||
|
|
@ -2925,6 +2980,7 @@ def _roadmap_slot_library_candidates(
|
||||||
anchor_variant_id=anchor_variant_id,
|
anchor_variant_id=anchor_variant_id,
|
||||||
used=pick_used,
|
used=pick_used,
|
||||||
slot_priority_exercise_id=None,
|
slot_priority_exercise_id=None,
|
||||||
|
skip_post_match_gate=skip_post_match_gate,
|
||||||
)
|
)
|
||||||
if not step or step.get("exercise_id") is None:
|
if not step or step.get("exercise_id") is None:
|
||||||
break
|
break
|
||||||
|
|
@ -2993,34 +3049,55 @@ def _run_unified_slot_improvement_review(
|
||||||
detail="unified_slot_review erfordert Roadmap (roadmap_override / roadmap_first)",
|
detail="unified_slot_review erfordert Roadmap (roadmap_override / roadmap_first)",
|
||||||
)
|
)
|
||||||
|
|
||||||
eval_body = body.model_copy(
|
|
||||||
update={
|
|
||||||
"include_llm_path_qa": body.include_llm_path_qa,
|
|
||||||
"include_ai_gap_fill": body.include_ai_gap_fill,
|
|
||||||
"auto_rematch_after_qa": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
baseline_steps = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps)
|
baseline_steps = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps)
|
||||||
qa_pack = _run_evaluate_only_path_qa(
|
snapshot = (
|
||||||
cur,
|
dict(body.baseline_path_qa_snapshot)
|
||||||
body=eval_body,
|
if isinstance(body.baseline_path_qa_snapshot, dict)
|
||||||
goal_query=goal_query,
|
else None
|
||||||
semantic_brief=semantic_brief,
|
|
||||||
steps=list(baseline_steps),
|
|
||||||
roadmap_ctx=roadmap_ctx,
|
|
||||||
)
|
)
|
||||||
baseline_steps = list(qa_pack.get("steps") or baseline_steps)
|
if snapshot:
|
||||||
baseline_qa = qa_pack.get("path_qa") if isinstance(qa_pack.get("path_qa"), dict) else {}
|
baseline_qa = snapshot
|
||||||
if baseline_qa.get("quality_score") is None:
|
if baseline_qa.get("quality_score") is None:
|
||||||
baseline_qa = dict(baseline_qa)
|
baseline_qa["quality_score"] = compute_deterministic_path_quality_score(
|
||||||
baseline_qa["quality_score"] = compute_deterministic_path_quality_score(
|
gaps=baseline_qa.get("large_gaps") or [],
|
||||||
gaps=baseline_qa.get("large_gaps") or [],
|
off_topic_steps=baseline_qa.get("off_topic_steps") or [],
|
||||||
off_topic_steps=baseline_qa.get("off_topic_steps") or [],
|
steps=baseline_steps,
|
||||||
steps=baseline_steps,
|
multistage_qa=baseline_qa,
|
||||||
multistage_qa=baseline_qa,
|
)
|
||||||
|
baseline_score = (
|
||||||
|
float(body.baseline_quality_score)
|
||||||
|
if body.baseline_quality_score is not None
|
||||||
|
else _path_qa_quality_score(baseline_qa)
|
||||||
)
|
)
|
||||||
baseline_score = _path_qa_quality_score(baseline_qa)
|
gap_fill_offers: List[Dict[str, Any]] = []
|
||||||
gap_fill_offers = list(qa_pack.get("gap_fill_offers") or [])
|
else:
|
||||||
|
eval_body = body.model_copy(
|
||||||
|
update={
|
||||||
|
"include_llm_path_qa": body.include_llm_path_qa,
|
||||||
|
"include_ai_gap_fill": body.include_ai_gap_fill,
|
||||||
|
"auto_rematch_after_qa": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
qa_pack = _run_evaluate_only_path_qa(
|
||||||
|
cur,
|
||||||
|
body=eval_body,
|
||||||
|
goal_query=goal_query,
|
||||||
|
semantic_brief=semantic_brief,
|
||||||
|
steps=list(baseline_steps),
|
||||||
|
roadmap_ctx=roadmap_ctx,
|
||||||
|
)
|
||||||
|
baseline_steps = list(qa_pack.get("steps") or baseline_steps)
|
||||||
|
baseline_qa = qa_pack.get("path_qa") if isinstance(qa_pack.get("path_qa"), dict) else {}
|
||||||
|
if baseline_qa.get("quality_score") is None:
|
||||||
|
baseline_qa = dict(baseline_qa)
|
||||||
|
baseline_qa["quality_score"] = compute_deterministic_path_quality_score(
|
||||||
|
gaps=baseline_qa.get("large_gaps") or [],
|
||||||
|
off_topic_steps=baseline_qa.get("off_topic_steps") or [],
|
||||||
|
steps=baseline_steps,
|
||||||
|
multistage_qa=baseline_qa,
|
||||||
|
)
|
||||||
|
baseline_score = _path_qa_quality_score(baseline_qa)
|
||||||
|
gap_fill_offers = list(qa_pack.get("gap_fill_offers") or [])
|
||||||
off_topic_map = _off_topic_reasons_by_slot(baseline_qa.get("off_topic_steps") or [])
|
off_topic_map = _off_topic_reasons_by_slot(baseline_qa.get("off_topic_steps") or [])
|
||||||
problem_slots = _problematic_slots_from_path_qa(
|
problem_slots = _problematic_slots_from_path_qa(
|
||||||
baseline_qa,
|
baseline_qa,
|
||||||
|
|
@ -3076,6 +3153,7 @@ def _run_unified_slot_improvement_review(
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
exclude_id = None
|
exclude_id = None
|
||||||
|
|
||||||
|
relax_match_gate = bool(off_topic or slot_problem)
|
||||||
candidates = _roadmap_slot_library_candidates(
|
candidates = _roadmap_slot_library_candidates(
|
||||||
cur,
|
cur,
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
|
|
@ -3093,8 +3171,9 @@ def _run_unified_slot_improvement_review(
|
||||||
anchor_id=anchor_id,
|
anchor_id=anchor_id,
|
||||||
anchor_variant_id=anchor_variant_id,
|
anchor_variant_id=anchor_variant_id,
|
||||||
used=used_other,
|
used=used_other,
|
||||||
exclude_exercise_id=exclude_id if not off_topic else int(current_id) if current_id else None,
|
exclude_exercise_id=int(current_id) if current_id is not None else exclude_id,
|
||||||
max_candidates=3,
|
max_candidates=5 if relax_match_gate else 3,
|
||||||
|
skip_post_match_gate=relax_match_gate,
|
||||||
)
|
)
|
||||||
|
|
||||||
accepted_for_slot = False
|
accepted_for_slot = False
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Schachstellen-Erkennung für unified Slot-Review."""
|
"""Schachstellen-Erkennung für unified Slot-Review."""
|
||||||
from planning_exercise_path_builder import (
|
from planning_exercise_path_builder import (
|
||||||
|
_parse_slot_refs_from_text,
|
||||||
_problematic_slots_from_path_qa,
|
_problematic_slots_from_path_qa,
|
||||||
_slot_suggestion_accepted,
|
_slot_suggestion_accepted,
|
||||||
)
|
)
|
||||||
|
|
@ -51,3 +52,46 @@ def test_slot_suggestion_accepted_for_problem_slot():
|
||||||
major_idx=1,
|
major_idx=1,
|
||||||
slot_problem=True,
|
slot_problem=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_slot_refs_schritt_is_one_based():
|
||||||
|
assert _parse_slot_refs_from_text("Schritt 8 (Ukemi Vorwärts) entfernen") == {7}
|
||||||
|
assert _parse_slot_refs_from_text("slot 3 und Stufe 5") == {2, 4}
|
||||||
|
|
||||||
|
|
||||||
|
def test_problematic_slots_from_refine_stage_spec_hint():
|
||||||
|
qa = {
|
||||||
|
"optimization_hints": [
|
||||||
|
{
|
||||||
|
"action": "refine_stage_spec",
|
||||||
|
"step_index": 7,
|
||||||
|
"issue": "stage_mismatch",
|
||||||
|
"reason": "Stufen-Fit zu schwach (0.00) für „Integration von Täuschung“",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"off_topic_steps": [],
|
||||||
|
}
|
||||||
|
steps = [
|
||||||
|
{"roadmap_major_step_index": i, "exercise_id": i + 1, "title": f"Übung {i + 1}"}
|
||||||
|
for i in range(8)
|
||||||
|
]
|
||||||
|
steps[7]["title"] = "Ukemi Vorwärts"
|
||||||
|
specs = [_spec(i) for i in range(8)]
|
||||||
|
problems = _problematic_slots_from_path_qa(qa, steps, specs)
|
||||||
|
assert 7 in problems
|
||||||
|
|
||||||
|
|
||||||
|
def test_problematic_slots_from_llm_schritt_text():
|
||||||
|
qa = {
|
||||||
|
"optimization_hints": [],
|
||||||
|
"off_topic_steps": [],
|
||||||
|
"issues": [
|
||||||
|
"Schritt 8 (Ukemi Vorwärts) hat keinen Bezug zur Kumite-Beinarbeit",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
steps = [
|
||||||
|
{"roadmap_major_step_index": 7, "exercise_id": 99, "title": "Ukemi Vorwärts"},
|
||||||
|
]
|
||||||
|
specs = [_spec(7)]
|
||||||
|
problems = _problematic_slots_from_path_qa(qa, steps, specs)
|
||||||
|
assert 7 in problems
|
||||||
|
|
|
||||||
|
|
@ -507,6 +507,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
evaluate_only: false,
|
evaluate_only: false,
|
||||||
unified_slot_review: true,
|
unified_slot_review: true,
|
||||||
baseline_evaluate_steps: slotsToEvaluateSteps(synced),
|
baseline_evaluate_steps: slotsToEvaluateSteps(synced),
|
||||||
|
baseline_path_qa_snapshot: baselineRes?.path_qa || null,
|
||||||
|
baseline_quality_score:
|
||||||
|
baselineRes?.path_qa?.quality_score != null
|
||||||
|
? Number(baselineRes.path_qa.quality_score)
|
||||||
|
: null,
|
||||||
include_llm_intent: false,
|
include_llm_intent: false,
|
||||||
auto_rematch_after_qa: false,
|
auto_rematch_after_qa: false,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -222,10 +222,50 @@ export default function ProgressionOptimizeCompareModal({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{dialogDiffs.length === 0 ? (
|
{dialogDiffs.length === 0 ? (
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
|
<>
|
||||||
Keine Verbesserung gefunden — dein Pfad ist für alle Slots bereits optimal bewertet
|
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
|
||||||
oder es fehlen passende Bibliotheks-Treffer (KI-Angebote im Bewertungs-Panel).
|
Keine Bibliotheks-Verbesserung mit messbarem Gewinn — Schachstellen siehe unten.
|
||||||
</p>
|
KI-Angebote im Bewertungs-Panel oder „Brücke / KI-Angebot“ nutzen.
|
||||||
|
</p>
|
||||||
|
{comparison?.problem_slots && Object.keys(comparison.problem_slots).length > 0 ? (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: '12px 0 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(comparison.problem_slots).map(([midxRaw, reasons]) => {
|
||||||
|
const midx = Number(midxRaw)
|
||||||
|
const reasonList = Array.isArray(reasons) ? reasons : [reasons]
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`problem-${midxRaw}`}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ color: 'var(--danger)' }}>
|
||||||
|
Schachstelle Slot {midx + 1}
|
||||||
|
</strong>
|
||||||
|
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||||
|
{reasonList.filter(Boolean).map((text, i) => (
|
||||||
|
<li key={`${midxRaw}-r-${i}`}>{text}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user