refs #699: preserve HMM policy MCP evidence
This commit is contained in:
@@ -26,6 +26,10 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e
|
|||||||
- HMM MCP 허용 도구는 `resolve_hr_term`, `search_hr_data`, `search_hr_policy` 세 개다.
|
- HMM MCP 허용 도구는 `resolve_hr_term`, `search_hr_data`, `search_hr_policy` 세 개다.
|
||||||
- 호출 인자는 `tools/list`의 schema를 기준으로 생성한다. 기본 도구에도 레거시 `prompt`/`limit`를
|
- 호출 인자는 `tools/list`의 schema를 기준으로 생성한다. 기본 도구에도 레거시 `prompt`/`limit`를
|
||||||
강제하지 않으며 `search_hr_data`·`search_hr_policy`는 `query`, `resolve_hr_term`은 `term`을 전달한다.
|
강제하지 않으며 `search_hr_data`·`search_hr_policy`는 `query`, `resolve_hr_term`은 `term`을 전달한다.
|
||||||
|
- MCP 호환 서버의 `status/result` 응답에서 `result` 문자열은 정책 본문 근거다. 화면 요약에는
|
||||||
|
길이와 앞부분을 표시하고, 최종 답변 합성에는 제한된 길이의 원문을 전달한다.
|
||||||
|
- 사용자 ID·역할·팀 문맥은 `search_hr_data`의 ‘나/우리 팀’ 해석에만 사용한다.
|
||||||
|
`search_hr_policy`와 `resolve_hr_term`에는 원 질문/원 용어만 전달해 벡터·lexical 검색어를 오염시키지 않는다.
|
||||||
|
|
||||||
## 런타임 구성
|
## 런타임 구성
|
||||||
|
|
||||||
@@ -34,6 +38,8 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e
|
|||||||
- `OCI_AUTH_TYPE=config_file`, `OCI_CONFIG_FILE`, `OCI_PROFILE`, `OCI_GENAI_COMPARTMENT_ID`는
|
- `OCI_AUTH_TYPE=config_file`, `OCI_CONFIG_FILE`, `OCI_PROFILE`, `OCI_GENAI_COMPARTMENT_ID`는
|
||||||
배포 서버 `/opt/hmm-poc4/.env`에서 관리하고 저장소에는 값을 기록하지 않는다.
|
배포 서버 `/opt/hmm-poc4/.env`에서 관리하고 저장소에는 값을 기록하지 않는다.
|
||||||
- MCP 호출 상세 JSON/code 영역은 공통 `presentation.py`에서 배경·글자색을 함께 고정한다.
|
- MCP 호출 상세 JSON/code 영역은 공통 `presentation.py`에서 배경·글자색을 함께 고정한다.
|
||||||
|
- 브라우저의 dark color-scheme과 관계없이 답변 목록, expander header, 보조 버튼도 밝은 배경과
|
||||||
|
어두운 글자색을 사용한다.
|
||||||
|
|
||||||
## 완료 기준
|
## 완료 기준
|
||||||
|
|
||||||
@@ -54,3 +60,7 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e
|
|||||||
- systemd `ExecStart`를 `/opt/hmm-poc4/.venv/bin/python -m streamlit ...`로 고정
|
- systemd `ExecStart`를 `/opt/hmm-poc4/.venv/bin/python -m streamlit ...`로 고정
|
||||||
- Chromium dark color-scheme에서 MCP JSON 계산값 확인:
|
- Chromium dark color-scheme에서 MCP JSON 계산값 확인:
|
||||||
배경 `rgb(246, 248, 250)`, 글자 `rgb(23, 43, 58)`
|
배경 `rgb(246, 248, 250)`, 글자 `rgb(23, 43, 58)`
|
||||||
|
- 정책 질문 브라우저 검증: 12월 31일 기준 사용촉진조치 시 미사용 연차 이월 없음과
|
||||||
|
입사 2년차부터 차기 발생연차 50% 선사용 제한을 답변했다.
|
||||||
|
- dark color-scheme 계산값: expander 배경 `rgb(246, 248, 250)`, 보조 버튼 배경
|
||||||
|
`rgb(255, 255, 255)`, 답변 목록·expander·버튼 글자 `rgb(23, 43, 58)`
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ from src.mcp_tool_router import (
|
|||||||
build_mcp_tool_arguments,
|
build_mcp_tool_arguments,
|
||||||
route_mcp_tool_across_servers_with_llm,
|
route_mcp_tool_across_servers_with_llm,
|
||||||
)
|
)
|
||||||
|
from src.mcp_result import (
|
||||||
|
has_actionable_text_result,
|
||||||
|
status_result_evidence,
|
||||||
|
status_result_summary,
|
||||||
|
text_result,
|
||||||
|
)
|
||||||
from src.oci_genai_sdk import (
|
from src.oci_genai_sdk import (
|
||||||
build_oci_genai_completion_client,
|
build_oci_genai_completion_client,
|
||||||
temperature_for_model_profile,
|
temperature_for_model_profile,
|
||||||
@@ -4108,7 +4114,7 @@ def _mcp_summary(mcp_result: Any) -> dict[str, Any]:
|
|||||||
return {"type": type(mcp_result).__name__}
|
return {"type": type(mcp_result).__name__}
|
||||||
payload = _mcp_response_payload(mcp_result)
|
payload = _mcp_response_payload(mcp_result)
|
||||||
items = _mcp_items(mcp_result)
|
items = _mcp_items(mcp_result)
|
||||||
summary: dict[str, Any] = {}
|
summary: dict[str, Any] = status_result_summary(mcp_result)
|
||||||
for key in ("toolName", "profile", "ordsPath", "generatedSql"):
|
for key in ("toolName", "profile", "ordsPath", "generatedSql"):
|
||||||
value = mcp_result.get(key) if key in mcp_result else payload.get(key)
|
value = mcp_result.get(key) if key in mcp_result else payload.get(key)
|
||||||
if value:
|
if value:
|
||||||
@@ -4195,6 +4201,8 @@ def _clean_agent_tool_query(value: object, fallback: str) -> str:
|
|||||||
def _mcp_has_actionable_result(mcp_result: Any) -> bool:
|
def _mcp_has_actionable_result(mcp_result: Any) -> bool:
|
||||||
if _mcp_generated_sql(mcp_result) or _mcp_items(mcp_result):
|
if _mcp_generated_sql(mcp_result) or _mcp_items(mcp_result):
|
||||||
return True
|
return True
|
||||||
|
if has_actionable_text_result(mcp_result):
|
||||||
|
return True
|
||||||
payload = _mcp_response_payload(mcp_result)
|
payload = _mcp_response_payload(mcp_result)
|
||||||
results = payload.get("results")
|
results = payload.get("results")
|
||||||
if isinstance(results, list) and results:
|
if isinstance(results, list) and results:
|
||||||
@@ -4259,7 +4267,10 @@ def _mcp_answer_evidence(
|
|||||||
if not isinstance(mcp_result, Mapping):
|
if not isinstance(mcp_result, Mapping):
|
||||||
return mcp_result
|
return mcp_result
|
||||||
payload = _mcp_response_payload(mcp_result)
|
payload = _mcp_response_payload(mcp_result)
|
||||||
evidence: dict[str, Any] = {}
|
evidence: dict[str, Any] = status_result_evidence(
|
||||||
|
mcp_result,
|
||||||
|
max_chars=max(3500, min(7000, max_text * 15)),
|
||||||
|
)
|
||||||
for key in (
|
for key in (
|
||||||
"toolName",
|
"toolName",
|
||||||
"profile",
|
"profile",
|
||||||
@@ -5468,7 +5479,7 @@ def _prepare_hmm_hr_tool_query(
|
|||||||
"""Prepare an HMM HR query without inheriting retired KB/VPD prompt rules."""
|
"""Prepare an HMM HR query without inheriting retired KB/VPD prompt rules."""
|
||||||
|
|
||||||
fallback = str(question or "").strip()
|
fallback = str(question or "").strip()
|
||||||
if tool.name == "resolve_hr_term":
|
if tool.name in {"resolve_hr_term", "search_hr_policy"}:
|
||||||
return fallback
|
return fallback
|
||||||
contextual_question = _hmm_demo_user_context(
|
contextual_question = _hmm_demo_user_context(
|
||||||
fallback,
|
fallback,
|
||||||
@@ -5646,6 +5657,8 @@ def synthesize_answer(
|
|||||||
"say that clearly. Treat items_count=0 or results_count=0 as an actual "
|
"say that clearly. Treat items_count=0 or results_count=0 as an actual "
|
||||||
"zero-row result, not as missing tool delivery. Never claim that an audit "
|
"zero-row result, not as missing tool delivery. Never claim that an audit "
|
||||||
"event exists unless its identifier or audit result is in the evidence. "
|
"event exists unless its identifier or audit result is in the evidence. "
|
||||||
|
"For compatibility MCP tools, the result string contains the factual "
|
||||||
|
"DOC and EVIDENCE lines; read it as evidence rather than treating it as metadata. "
|
||||||
"For cross-source questions, keep structured contract/product facts separate "
|
"For cross-source questions, keep structured contract/product facts separate "
|
||||||
"from vector clause evidence, then combine only matching identifiers. If one "
|
"from vector clause evidence, then combine only matching identifiers. If one "
|
||||||
"source is missing, list confirmed and unconfirmed points separately instead "
|
"source is missing, list confirmed and unconfirmed points separately instead "
|
||||||
@@ -5781,6 +5794,11 @@ def fallback_answer_from_mcp(
|
|||||||
lines.append(f"- {title or '검색 결과'}{suffix}: {body}")
|
lines.append(f"- {title or '검색 결과'}{suffix}: {body}")
|
||||||
else:
|
else:
|
||||||
lines.append(f"- {compact}")
|
lines.append(f"- {compact}")
|
||||||
|
elif text_result(mcp_result):
|
||||||
|
result = text_result(mcp_result)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("MCP 반환 근거:")
|
||||||
|
lines.append(result[:6000] + ("..." if len(result) > 6000 else ""))
|
||||||
elif agent_steps:
|
elif agent_steps:
|
||||||
lines.append(f"Agent는 MCP tool을 {len(agent_steps)}회 호출했습니다.")
|
lines.append(f"Agent는 MCP tool을 {len(agent_steps)}회 호출했습니다.")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
|
|||||||
[data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span,
|
[data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span,
|
||||||
[data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1,
|
[data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1,
|
||||||
[data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3,
|
[data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3,
|
||||||
|
[data-testid="stAppViewContainer"] li, [data-testid="stAppViewContainer"] dt,
|
||||||
|
[data-testid="stAppViewContainer"] dd, [data-testid="stAppViewContainer"] blockquote,
|
||||||
[data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea,
|
[data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea,
|
||||||
section[data-testid="stSidebar"] * {{ color:var(--console-text) !important;
|
section[data-testid="stSidebar"] * {{ color:var(--console-text) !important;
|
||||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
@@ -34,7 +36,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
|
|||||||
background:#fff !important; border:1px solid var(--console-border) !important;
|
background:#fff !important; border:1px solid var(--console-border) !important;
|
||||||
border-radius:4px !important; box-shadow:none !important; }}
|
border-radius:4px !important; box-shadow:none !important; }}
|
||||||
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
|
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
|
||||||
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
border:1px solid var(--console-border) !important;
|
||||||
border-radius:4px !important; box-shadow:none !important; }}
|
border-radius:4px !important; box-shadow:none !important; }}
|
||||||
|
div[data-testid="stButton"] > button *,
|
||||||
|
div[data-testid="stFormSubmitButton"] > button * {{
|
||||||
|
color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
div[data-testid="stButton"] > button[kind="primary"],
|
div[data-testid="stButton"] > button[kind="primary"],
|
||||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
|
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
|
||||||
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
|
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
|
||||||
@@ -54,6 +61,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
|
|||||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
[data-testid="stJson"] button, [data-testid="stCodeBlock"] button, [data-testid="stCode"] button {{
|
[data-testid="stJson"] button, [data-testid="stCodeBlock"] button, [data-testid="stCode"] button {{
|
||||||
background:#fff !important; border-color:var(--console-border) !important; }}
|
background:#fff !important; border-color:var(--console-border) !important; }}
|
||||||
|
[data-testid="stExpander"] summary {{
|
||||||
|
background:#f6f8fa !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; }}
|
||||||
|
[data-testid="stExpander"] summary * {{
|
||||||
|
color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
.console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }}
|
.console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }}
|
||||||
.console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }}
|
.console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }}
|
||||||
.console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }}
|
.console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }}
|
||||||
|
|||||||
80
poc4_active_source_20260714/src/mcp_result.py
Normal file
80
poc4_active_source_20260714/src/mcp_result.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Pure helpers for MCP result envelopes used by the Streamlit console."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def response_payload(value: Any) -> Mapping[str, Any]:
|
||||||
|
"""Return the business payload from a direct or nested MCP response."""
|
||||||
|
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
return {}
|
||||||
|
nested = value.get("response")
|
||||||
|
return nested if isinstance(nested, Mapping) else value
|
||||||
|
|
||||||
|
|
||||||
|
def text_result(value: Any) -> str:
|
||||||
|
"""Return a textual `result` field without stringifying other structures."""
|
||||||
|
|
||||||
|
result = response_payload(value).get("result")
|
||||||
|
return result.strip() if isinstance(result, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def status_result_summary(value: Any, *, excerpt_chars: int = 900) -> dict[str, Any]:
|
||||||
|
"""Build a safe UI summary for status/result-style compatibility tools."""
|
||||||
|
|
||||||
|
payload = response_payload(value)
|
||||||
|
summary: dict[str, Any] = {}
|
||||||
|
for key in ("status", "success", "error", "errorCode", "errorMessage"):
|
||||||
|
item = payload.get(key)
|
||||||
|
if item not in (None, "", []):
|
||||||
|
summary[key] = item
|
||||||
|
result = text_result(value)
|
||||||
|
if result:
|
||||||
|
summary["result_chars"] = len(result)
|
||||||
|
summary["result_excerpt"] = result[:excerpt_chars] + (
|
||||||
|
"..." if len(result) > excerpt_chars else ""
|
||||||
|
)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def status_result_evidence(value: Any, *, max_chars: int = 7000) -> dict[str, Any]:
|
||||||
|
"""Preserve bounded textual policy/data evidence for final answer synthesis."""
|
||||||
|
|
||||||
|
payload = response_payload(value)
|
||||||
|
evidence: dict[str, Any] = {}
|
||||||
|
for key in ("status", "success", "error", "errorCode", "errorMessage"):
|
||||||
|
item = payload.get(key)
|
||||||
|
if item not in (None, "", []):
|
||||||
|
evidence[key] = item
|
||||||
|
result = text_result(value)
|
||||||
|
if result:
|
||||||
|
evidence["result"] = result[:max_chars] + (
|
||||||
|
"..." if len(result) > max_chars else ""
|
||||||
|
)
|
||||||
|
evidence["result_chars"] = len(result)
|
||||||
|
return evidence
|
||||||
|
|
||||||
|
|
||||||
|
def has_actionable_text_result(value: Any) -> bool:
|
||||||
|
"""Return whether a textual result contains evidence worth stopping on."""
|
||||||
|
|
||||||
|
result = text_result(value)
|
||||||
|
if not result:
|
||||||
|
return False
|
||||||
|
normalized = " ".join(result.casefold().split())
|
||||||
|
return not any(
|
||||||
|
marker in normalized
|
||||||
|
for marker in ("no data found", "no evidence found", "error:")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"has_actionable_text_result",
|
||||||
|
"response_payload",
|
||||||
|
"status_result_evidence",
|
||||||
|
"status_result_summary",
|
||||||
|
"text_result",
|
||||||
|
]
|
||||||
@@ -9,6 +9,21 @@ from unittest.mock import patch
|
|||||||
from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
|
from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
|
||||||
from src.agent_console.profile import load_app_profile
|
from src.agent_console.profile import load_app_profile
|
||||||
from src.mcp_tool_router import McpTool, build_mcp_tool_arguments
|
from src.mcp_tool_router import McpTool, build_mcp_tool_arguments
|
||||||
|
from src.mcp_result import (
|
||||||
|
has_actionable_text_result,
|
||||||
|
status_result_evidence,
|
||||||
|
status_result_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_poc4_query_helpers():
|
||||||
|
"""Load the Streamlit entrypoint only when its optional runtime is installed."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apps.poc4.mcp_discovery_ui import _prepare_hmm_hr_tool_query
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
return None
|
||||||
|
return _prepare_hmm_hr_tool_query
|
||||||
|
|
||||||
|
|
||||||
class DemoScenarioConfigTest(unittest.TestCase):
|
class DemoScenarioConfigTest(unittest.TestCase):
|
||||||
@@ -38,6 +53,14 @@ class DemoScenarioConfigTest(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(profile.short_name, "HMM")
|
self.assertEqual(profile.short_name, "HMM")
|
||||||
|
|
||||||
|
def test_common_theme_covers_lists_expanders_and_secondary_buttons(self) -> None:
|
||||||
|
path = Path(__file__).parents[1] / "src" / "agent_console" / "presentation.py"
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn('[data-testid="stAppViewContainer"] li', source)
|
||||||
|
self.assertIn('[data-testid="stExpander"] summary', source)
|
||||||
|
self.assertIn('div[data-testid="stButton"] > button', source)
|
||||||
|
|
||||||
def test_hmm_scenarios_are_enabled_and_unique(self) -> None:
|
def test_hmm_scenarios_are_enabled_and_unique(self) -> None:
|
||||||
path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json"
|
path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json"
|
||||||
scenarios = load_demo_scenarios(path)
|
scenarios = load_demo_scenarios(path)
|
||||||
@@ -111,6 +134,51 @@ class DemoScenarioConfigTest(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(arguments, {"term": "반차"})
|
self.assertEqual(arguments, {"term": "반차"})
|
||||||
|
|
||||||
|
def test_status_result_policy_text_is_preserved_as_answer_evidence(self) -> None:
|
||||||
|
result = {
|
||||||
|
"status": "success",
|
||||||
|
"result": (
|
||||||
|
"HR_POLICY_SEARCH_RESULT\n"
|
||||||
|
"EVIDENCE|file=KR_Leave_Policy.pdf|chunk=13|text=이월 기준"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = status_result_summary(result, excerpt_chars=40)
|
||||||
|
evidence = status_result_evidence(result)
|
||||||
|
|
||||||
|
self.assertEqual(summary["status"], "success")
|
||||||
|
self.assertGreater(summary["result_chars"], 40)
|
||||||
|
self.assertIn("KR_Leave_Policy.pdf", evidence["result"])
|
||||||
|
self.assertTrue(has_actionable_text_result(result))
|
||||||
|
|
||||||
|
def test_no_data_text_is_not_actionable(self) -> None:
|
||||||
|
self.assertFalse(
|
||||||
|
has_actionable_text_result({"status": "success", "result": "No data found"})
|
||||||
|
)
|
||||||
|
|
||||||
|
@unittest.skipIf(_load_poc4_query_helpers() is None, "Streamlit runtime is optional")
|
||||||
|
def test_policy_query_does_not_include_demo_user_context(self) -> None:
|
||||||
|
prepare = _load_poc4_query_helpers()
|
||||||
|
assert prepare is not None
|
||||||
|
tool = McpTool(
|
||||||
|
name="search_hr_policy",
|
||||||
|
description="Search policy documents",
|
||||||
|
schema={"properties": {"query": {"type": "string"}}},
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
query = prepare(
|
||||||
|
question="연차 휴가 이월 기준과 제한을 알려줘",
|
||||||
|
tool=tool,
|
||||||
|
model_profile_key="gpt54_mini_oci",
|
||||||
|
selected_user_id="E1001",
|
||||||
|
selected_user_role="HR Team Manager",
|
||||||
|
selected_user_team="HMM HR Demo Team",
|
||||||
|
selected_user_scope="팀원 6명 관리",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(query, "연차 휴가 이월 기준과 제한을 알려줘")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user