ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов 2.30: вынос ветки tool_gate_skip + chat fallback из assistantService в отдельный runtime-адаптер, чтобы в сервисе остался только orchestration flow.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runAssistantAddressToolGateRuntime } from "../src/services/assistantAddressToolGateRuntimeAdapter";
|
||||
|
||||
describe("assistant address tool-gate runtime adapter", () => {
|
||||
it("does nothing when runAddressLane is true", async () => {
|
||||
const logEvent = vi.fn();
|
||||
const tryHandleLivingChat = vi.fn(async () => "chat-response");
|
||||
|
||||
const result = await runAssistantAddressToolGateRuntime({
|
||||
sessionId: "asst-1",
|
||||
userMessage: "вопрос",
|
||||
addressInputMessage: "вопрос",
|
||||
orchestrationDecision: { runAddressLane: true },
|
||||
livingModeDecision: { mode: "chat", reason: "x" },
|
||||
addressRuntimeMeta: {},
|
||||
logEvent,
|
||||
tryHandleLivingChat,
|
||||
nowIso: () => "2026-04-10T00:00:00.000Z"
|
||||
});
|
||||
|
||||
expect(result.handled).toBe(false);
|
||||
expect(result.response).toBeNull();
|
||||
expect(logEvent).not.toHaveBeenCalled();
|
||||
expect(tryHandleLivingChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs skip and returns chat response when chat fallback handles", async () => {
|
||||
const logEvent = vi.fn();
|
||||
const tryHandleLivingChat = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
const result = await runAssistantAddressToolGateRuntime({
|
||||
sessionId: "asst-2",
|
||||
userMessage: "вопрос",
|
||||
addressInputMessage: "канон",
|
||||
orchestrationDecision: { runAddressLane: false },
|
||||
livingModeDecision: { mode: "chat", reason: "predecompose_unsupported_mode" },
|
||||
addressRuntimeMeta: {
|
||||
attempted: true,
|
||||
applied: false,
|
||||
reason: "normalize_failed",
|
||||
predecomposeContract: {
|
||||
intent: "unknown",
|
||||
aggregation_profile: "unknown",
|
||||
period: { scope: "unspecified" }
|
||||
}
|
||||
},
|
||||
logEvent,
|
||||
tryHandleLivingChat,
|
||||
nowIso: () => "2026-04-10T00:00:00.000Z"
|
||||
});
|
||||
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.response).toEqual({ ok: true });
|
||||
expect(logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(tryHandleLivingChat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs skip and returns unhandled when mode is not chat", async () => {
|
||||
const logEvent = vi.fn();
|
||||
const tryHandleLivingChat = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
const result = await runAssistantAddressToolGateRuntime({
|
||||
sessionId: "asst-3",
|
||||
userMessage: "вопрос",
|
||||
addressInputMessage: "канон",
|
||||
orchestrationDecision: { runAddressLane: false },
|
||||
livingModeDecision: { mode: "deep_analysis", reason: "strong_data_signal_detected" },
|
||||
addressRuntimeMeta: {},
|
||||
logEvent,
|
||||
tryHandleLivingChat,
|
||||
nowIso: () => "2026-04-10T00:00:00.000Z"
|
||||
});
|
||||
|
||||
expect(result.handled).toBe(false);
|
||||
expect(result.response).toBeNull();
|
||||
expect(logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(tryHandleLivingChat).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runAssistantDeepTurnAnalysisRuntime } from "../src/services/assistantDeepTurnAnalysisRuntimeAdapter";
|
||||
|
||||
describe("assistant deep turn analysis runtime adapter", () => {
|
||||
it("orchestrates deep pipeline steps in deterministic order", async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const runContextRuntime = vi.fn(() => {
|
||||
callOrder.push("context");
|
||||
return {
|
||||
companyAnchors: { accounts: ["60.01"] },
|
||||
focusDomainForGuards: "settlements_60_62",
|
||||
temporalGuard: { primary_period_window: { from: "2020-07-01", to: "2020-07-31" } },
|
||||
domainPolarityGuardInitial: { polarity: "supplier_payable" },
|
||||
claimAnchorAudit: { claim_type: "prove_settlement_closure_state" },
|
||||
businessScopeResolution: { business_scope_resolved: ["company_specific_accounting"] },
|
||||
resolvedRouteSummary: { mode: "deterministic_v2", decisions: [] as any[] } as any,
|
||||
liveTemporalHint: {
|
||||
as_of_date: "2020-07-31",
|
||||
period_from: null,
|
||||
period_to: null,
|
||||
source: "analysis_context"
|
||||
}
|
||||
};
|
||||
});
|
||||
const runExecutionPlanRuntime = vi.fn((input) => {
|
||||
callOrder.push("plan");
|
||||
expect(input.claimAnchorAudit.claim_type).toBe("prove_settlement_closure_state");
|
||||
return {
|
||||
requirementExtraction: {
|
||||
requirements: [{ id: "R1" }],
|
||||
byFragment: new Map<string, string[]>([["F1", ["R1"]]])
|
||||
},
|
||||
executionPlan: [{ fragment_id: "F1" }],
|
||||
rbpRoutePlanEnforcement: { executionPlan: [{ fragment_id: "F1" }], audit: { rbp: true } },
|
||||
faRoutePlanEnforcement: { executionPlan: [{ fragment_id: "F1" }], audit: { fa: true } }
|
||||
} as any;
|
||||
});
|
||||
const runRetrievalRuntime = vi.fn(async (input) => {
|
||||
callOrder.push("retrieval");
|
||||
expect(input.liveTemporalHint?.as_of_date).toBe("2020-07-31");
|
||||
return {
|
||||
retrievalCalls: [{ fragment_id: "F1", route: "store_canonical" }],
|
||||
retrievalResultsRaw: [{ fragment_id: "F1", route: "store_canonical", raw_result: {} }],
|
||||
retrievalResults: [{ fragment_id: "F1", requirement_ids: ["R1"] }]
|
||||
} as any;
|
||||
});
|
||||
const runGuardRuntime = vi.fn((input) => {
|
||||
callOrder.push("guard");
|
||||
expect(input.retrievalResults[0].fragment_id).toBe("F1");
|
||||
return {
|
||||
retrievalResults: [{ fragment_id: "F1-guarded", requirement_ids: ["R1"] }],
|
||||
polarityGuardResult: { audit: { polarity: "supplier_payable" } },
|
||||
targetedEvidenceResult: {
|
||||
audit: {
|
||||
targeted_evidence_hit_rate: 0.5
|
||||
}
|
||||
},
|
||||
evidenceGateResult: {
|
||||
audit: {
|
||||
admissible_evidence_count: 3
|
||||
}
|
||||
}
|
||||
} as any;
|
||||
});
|
||||
const runGroundingRuntime = vi.fn((input) => {
|
||||
callOrder.push("grounding");
|
||||
expect(input.claimType).toBe("prove_settlement_closure_state");
|
||||
expect(input.targetedEvidenceHitRate).toBe(0.5);
|
||||
expect(input.businessScopeResolved).toEqual(["company_specific_accounting"]);
|
||||
return {
|
||||
rbpLiveRouteAudit: { routed: 1 },
|
||||
faLiveRouteAudit: { routed: 1 },
|
||||
coverageEvaluation: {
|
||||
requirements: [{ id: "R1" }],
|
||||
coverage: { requirements_total: 1, requirements_covered: 1 }
|
||||
},
|
||||
groundedAnswerEligibilityGuard: { eligible: true },
|
||||
groundingCheck: { status: "grounded", reasons: [] }
|
||||
} as any;
|
||||
});
|
||||
const runCompositionRuntime = vi.fn((input) => {
|
||||
callOrder.push("composition");
|
||||
expect(input.retrievalResults[0].fragment_id).toBe("F1-guarded");
|
||||
return {
|
||||
questionTypeClass: "causal_trace",
|
||||
composition: { reply_type: "factual", answer: "ok" }
|
||||
} as any;
|
||||
});
|
||||
|
||||
const runtime = await runAssistantDeepTurnAnalysisRuntime({
|
||||
userMessage: "where closure failed",
|
||||
runContextRuntime,
|
||||
runExecutionPlanRuntime,
|
||||
runRetrievalRuntime,
|
||||
runGuardRuntime,
|
||||
runGroundingRuntime,
|
||||
runCompositionRuntime
|
||||
});
|
||||
|
||||
expect(callOrder).toEqual(["context", "plan", "retrieval", "guard", "grounding", "composition"]);
|
||||
expect(runtime.retrievalResults[0].fragment_id).toBe("F1-guarded");
|
||||
expect(runtime.questionTypeClass).toBe("causal_trace");
|
||||
expect(runtime.composition.reply_type).toBe("factual");
|
||||
});
|
||||
|
||||
it("passes null business scope when not resolved", async () => {
|
||||
const runGroundingRuntime = vi.fn(() => ({
|
||||
rbpLiveRouteAudit: {},
|
||||
faLiveRouteAudit: {},
|
||||
coverageEvaluation: { requirements: [], coverage: {} },
|
||||
groundedAnswerEligibilityGuard: {},
|
||||
groundingCheck: { status: "no_grounded_answer", reasons: [] }
|
||||
}));
|
||||
|
||||
await runAssistantDeepTurnAnalysisRuntime({
|
||||
userMessage: "question",
|
||||
runContextRuntime: () =>
|
||||
({
|
||||
companyAnchors: {},
|
||||
focusDomainForGuards: null,
|
||||
temporalGuard: {},
|
||||
domainPolarityGuardInitial: {},
|
||||
claimAnchorAudit: { claim_type: "unknown" },
|
||||
businessScopeResolution: {},
|
||||
resolvedRouteSummary: null,
|
||||
liveTemporalHint: null
|
||||
}) as any,
|
||||
runExecutionPlanRuntime: () =>
|
||||
({
|
||||
requirementExtraction: { requirements: [], byFragment: new Map() },
|
||||
executionPlan: [],
|
||||
rbpRoutePlanEnforcement: { executionPlan: [], audit: {} },
|
||||
faRoutePlanEnforcement: { executionPlan: [], audit: {} }
|
||||
}) as any,
|
||||
runRetrievalRuntime: async () =>
|
||||
({
|
||||
retrievalCalls: [],
|
||||
retrievalResultsRaw: [],
|
||||
retrievalResults: []
|
||||
}) as any,
|
||||
runGuardRuntime: () =>
|
||||
({
|
||||
retrievalResults: [],
|
||||
polarityGuardResult: { audit: {} },
|
||||
targetedEvidenceResult: { audit: { targeted_evidence_hit_rate: null } },
|
||||
evidenceGateResult: { audit: {} }
|
||||
}) as any,
|
||||
runGroundingRuntime: runGroundingRuntime as any,
|
||||
runCompositionRuntime: () =>
|
||||
({
|
||||
questionTypeClass: "single_fact_lookup",
|
||||
composition: { reply_type: "partial_coverage" }
|
||||
}) as any
|
||||
});
|
||||
|
||||
expect(runGroundingRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
routeSummary: null,
|
||||
businessScopeResolved: null
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildAssistantDeepTurnNormalizationRuntime } from "../src/services/assistantDeepTurnNormalizationRuntimeAdapter";
|
||||
|
||||
describe("assistant deep turn normalization runtime adapter", () => {
|
||||
it("uses followup state binding when feature flags are enabled and state exists", async () => {
|
||||
const followupBinding = {
|
||||
normalizedQuestion: "normalized question",
|
||||
mergedContext: {
|
||||
period_hint: "2020-07",
|
||||
business_context: "ctx-from-followup"
|
||||
},
|
||||
usage: {
|
||||
applied: true
|
||||
}
|
||||
};
|
||||
const buildFollowupStateBinding = vi.fn(() => followupBinding);
|
||||
const normalize = vi.fn(async (request) => ({
|
||||
trace_id: "trace-1",
|
||||
ok: true,
|
||||
normalized: { schema_version: "normalized_query_v2_0_2" } as any,
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
validation: { passed: true, errors: [] },
|
||||
usage: { input_tokens: 10, output_tokens: 20, total_tokens: 30 },
|
||||
latency_ms: 7,
|
||||
prompt_version: String(request.promptVersion ?? ""),
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
request_count_for_case: 1
|
||||
}));
|
||||
|
||||
const runtime = await buildAssistantDeepTurnNormalizationRuntime({
|
||||
userMessage: "raw question",
|
||||
payload: {
|
||||
llmProvider: "openai",
|
||||
apiKey: "k",
|
||||
model: "m",
|
||||
baseUrl: "https://api.example.com",
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 333,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
systemPrompt: "sys",
|
||||
developerPrompt: "dev",
|
||||
domainPrompt: "dom",
|
||||
fewShotExamples: "few",
|
||||
context: {
|
||||
period_hint: "2020-06"
|
||||
},
|
||||
useMock: true
|
||||
},
|
||||
featureInvestigationStateV1: true,
|
||||
featureStateFollowupBindingV1: true,
|
||||
sessionInvestigationState: {
|
||||
active_domain: "settlements_60_62"
|
||||
},
|
||||
buildFollowupStateBinding,
|
||||
normalize
|
||||
});
|
||||
|
||||
expect(buildFollowupStateBinding).toHaveBeenCalledTimes(1);
|
||||
expect(normalize).toHaveBeenCalledTimes(1);
|
||||
expect(normalize).toHaveBeenCalledWith({
|
||||
llmProvider: "openai",
|
||||
apiKey: "k",
|
||||
model: "m",
|
||||
baseUrl: "https://api.example.com",
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 333,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
systemPrompt: "sys",
|
||||
developerPrompt: "dev",
|
||||
domainPrompt: "dom",
|
||||
fewShotExamples: "few",
|
||||
userQuestion: "normalized question",
|
||||
context: {
|
||||
period_hint: "2020-07",
|
||||
business_context: "ctx-from-followup"
|
||||
},
|
||||
useMock: true
|
||||
});
|
||||
expect(runtime.followupBinding).toBe(followupBinding);
|
||||
expect(runtime.normalizePayload.userQuestion).toBe("normalized question");
|
||||
});
|
||||
|
||||
it("falls back to raw user message when followup binding is disabled", async () => {
|
||||
const buildFollowupStateBinding = vi.fn();
|
||||
const normalize = vi.fn(async () => ({
|
||||
trace_id: "trace-2",
|
||||
ok: true,
|
||||
normalized: { schema_version: "normalized_query_v2_0_2" } as any,
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
validation: { passed: true, errors: [] },
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
latency_ms: 1,
|
||||
prompt_version: "address_query_runtime_v1",
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
request_count_for_case: 1
|
||||
}));
|
||||
|
||||
const runtime = await buildAssistantDeepTurnNormalizationRuntime({
|
||||
userMessage: "raw fallback",
|
||||
payload: {
|
||||
llmProvider: "openai",
|
||||
context: {
|
||||
business_context: "payload-context"
|
||||
},
|
||||
useMock: undefined
|
||||
},
|
||||
featureInvestigationStateV1: false,
|
||||
featureStateFollowupBindingV1: true,
|
||||
sessionInvestigationState: {
|
||||
some: "state"
|
||||
},
|
||||
buildFollowupStateBinding,
|
||||
normalize
|
||||
});
|
||||
|
||||
expect(buildFollowupStateBinding).not.toHaveBeenCalled();
|
||||
expect(normalize).toHaveBeenCalledWith({
|
||||
llmProvider: "openai",
|
||||
apiKey: undefined,
|
||||
model: undefined,
|
||||
baseUrl: undefined,
|
||||
temperature: undefined,
|
||||
maxOutputTokens: undefined,
|
||||
promptVersion: "address_query_runtime_v1",
|
||||
systemPrompt: undefined,
|
||||
developerPrompt: undefined,
|
||||
domainPrompt: undefined,
|
||||
fewShotExamples: undefined,
|
||||
userQuestion: "raw fallback",
|
||||
context: {
|
||||
business_context: "payload-context"
|
||||
},
|
||||
useMock: false
|
||||
});
|
||||
expect(runtime.followupBinding).toEqual({
|
||||
normalizedQuestion: "raw fallback",
|
||||
mergedContext: {
|
||||
business_context: "payload-context"
|
||||
},
|
||||
usage: null
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runAssistantDeepTurnResponseRuntime } from "../src/services/assistantDeepTurnResponseRuntimeAdapter";
|
||||
|
||||
function buildBaseInput(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
featureInvestigationStateV1: true,
|
||||
featureContractsV11: true,
|
||||
featureAnswerPolicyV11: true,
|
||||
sessionId: "asst-1",
|
||||
questionId: "msg-q1",
|
||||
userMessage: "question",
|
||||
normalized: {
|
||||
trace_id: "trace-1",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
normalized: { schema_version: "normalized_query_v2_0_2" }
|
||||
},
|
||||
normalizedQuestion: "normalized-question",
|
||||
routeSummary: { mode: "deterministic_v2", decisions: [] as any[] },
|
||||
executionPlan: [],
|
||||
requirementExtractionRequirements: [],
|
||||
coverageEvaluationRequirements: [],
|
||||
coverageReport: {},
|
||||
groundingCheck: { status: "no_grounded_answer", reasons: [] },
|
||||
retrievalCalls: [],
|
||||
retrievalResultsRaw: [],
|
||||
retrievalResults: [],
|
||||
questionTypeClass: "single_fact_lookup",
|
||||
companyAnchors: {},
|
||||
runtimeAnalysisContext: {},
|
||||
businessScopeResolution: {},
|
||||
temporalGuard: {},
|
||||
polarityAudit: {},
|
||||
claimAnchorAudit: {},
|
||||
targetedEvidenceAudit: {},
|
||||
evidenceAdmissibilityGateAudit: {},
|
||||
rbpLiveRouteAudit: {},
|
||||
faLiveRouteAudit: {},
|
||||
groundedAnswerEligibilityGuard: {},
|
||||
followupStateUsage: null,
|
||||
followupApplied: false,
|
||||
composition: {
|
||||
reply_type: "factual",
|
||||
assistant_reply: "assistant answer"
|
||||
},
|
||||
previousInvestigationState: null,
|
||||
addressRuntimeMetaForDeep: null,
|
||||
extractDroppedIntentSegments: () => [],
|
||||
buildDebugRoutes: () => [],
|
||||
extractExecutionState: () => [],
|
||||
sanitizeReply: (value: string) => value,
|
||||
persistInvestigationState: () => {},
|
||||
messageIdFactory: () => "msg-a1",
|
||||
appendItem: () => {},
|
||||
getSession: () => ({ session_id: "asst-1", items: [] }),
|
||||
persistSession: () => {},
|
||||
cloneConversation: () => [],
|
||||
logEvent: () => {},
|
||||
...overrides
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("assistant deep turn response runtime adapter", () => {
|
||||
it("wires packaging output into deep finalization", () => {
|
||||
const runPackagingRuntime = vi.fn(() => ({
|
||||
messageId: "msg-a1",
|
||||
investigationStateSnapshot: null,
|
||||
droppedIntentSegments: [],
|
||||
analysisContextForContract: null,
|
||||
routesForDebug: [],
|
||||
resolvedExecutionState: [],
|
||||
safeAssistantReplyBase: "base",
|
||||
safeAssistantReply: "safe-reply",
|
||||
debug: { trace_id: "trace-1" },
|
||||
assistantItem: {
|
||||
message_id: "msg-a1",
|
||||
session_id: "asst-1",
|
||||
role: "assistant",
|
||||
text: "safe-reply",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-10T00:00:00.000Z",
|
||||
trace_id: "trace-1",
|
||||
debug: null
|
||||
},
|
||||
deepAnalysisLogDetails: { info: "ok" }
|
||||
}));
|
||||
const responsePayload = {
|
||||
ok: true,
|
||||
session_id: "asst-1",
|
||||
conversation: [],
|
||||
debug: { trace_id: "trace-1" }
|
||||
};
|
||||
const runFinalizeDeepTurn = vi.fn(() => ({
|
||||
response: responsePayload
|
||||
}));
|
||||
|
||||
const runtime = runAssistantDeepTurnResponseRuntime(
|
||||
buildBaseInput({
|
||||
runPackagingRuntime,
|
||||
runFinalizeDeepTurn
|
||||
})
|
||||
);
|
||||
|
||||
expect(runPackagingRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(runFinalizeDeepTurn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "asst-1",
|
||||
assistantReply: "safe-reply",
|
||||
replyType: "factual"
|
||||
})
|
||||
);
|
||||
expect(runtime.response).toEqual(responsePayload);
|
||||
expect(runtime.debug).toEqual({ trace_id: "trace-1" });
|
||||
});
|
||||
|
||||
it("passes feature flags and followup flags into packaging stage", () => {
|
||||
const runPackagingRuntime = vi.fn(() => ({
|
||||
messageId: "msg-a1",
|
||||
investigationStateSnapshot: null,
|
||||
droppedIntentSegments: [],
|
||||
analysisContextForContract: null,
|
||||
routesForDebug: [],
|
||||
resolvedExecutionState: [],
|
||||
safeAssistantReplyBase: "base",
|
||||
safeAssistantReply: "safe-reply",
|
||||
debug: {},
|
||||
assistantItem: {
|
||||
message_id: "msg-a1",
|
||||
session_id: "asst-1",
|
||||
role: "assistant",
|
||||
text: "safe-reply",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-10T00:00:00.000Z",
|
||||
trace_id: "trace-1",
|
||||
debug: null
|
||||
},
|
||||
deepAnalysisLogDetails: {}
|
||||
}));
|
||||
|
||||
runAssistantDeepTurnResponseRuntime(
|
||||
buildBaseInput({
|
||||
featureInvestigationStateV1: false,
|
||||
followupApplied: true,
|
||||
runPackagingRuntime,
|
||||
runFinalizeDeepTurn: () => ({
|
||||
response: {
|
||||
ok: true,
|
||||
session_id: "asst-1",
|
||||
conversation: [],
|
||||
debug: null
|
||||
}
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
expect(runPackagingRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
featureInvestigationStateV1: false,
|
||||
followupApplied: true
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user