Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/data/src/supabase-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,7 +835,11 @@ export class SupabaseStore implements DataStore {
p_owner_user_id: ownerUserId
});
if (error) this.throwError("读取虚拟测试用户池", error);
return adventurexTestPoolStatusSchema.parse(unwrapRpcData(data));
const row = unwrapRpcData(data) as JsonRow;
return adventurexTestPoolStatusSchema.parse({
...row,
updatedAt: normalizeDateTime(row.updatedAt ?? row.updated_at)
});
}

async configureAdventurexTestPool(
Expand All @@ -848,7 +852,11 @@ export class SupabaseStore implements DataStore {
p_desired_user_count: input.desiredUserCount
});
if (error) this.throwError("配置虚拟测试用户池", error);
return adventurexTestPoolStatusSchema.parse(unwrapRpcData(data));
const row = unwrapRpcData(data) as JsonRow;
return adventurexTestPoolStatusSchema.parse({
...row,
updatedAt: normalizeDateTime(row.updatedAt ?? row.updated_at)
});
}

async prepareAdventurexTestPool(ownerUserId: string): Promise<MatchRequest[]> {
Expand Down
136 changes: 133 additions & 3 deletions packages/intelligence/src/hosted-llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,134 @@ afterEach(() => {
vi.unstubAllGlobals();
});

describe("hosted structured output compatibility", () => {
it("includes a lowercase json marker when requesting json_object output", async () => {
const now = new Date().toISOString();
const requestBodies = stubChatResponses({
drafts: [{
tempDraftId: "draft-1",
offlineGameId: "game-story-table",
targetPlayers: [2],
candidateRequestIds: ["request-1", "request-2"],
rationale: "A two-person activity"
}],
userOptions: [
{ requestId: "request-1", tempDraftIds: ["draft-1"] },
{ requestId: "request-2", tempDraftIds: ["draft-1"] }
]
});
const candidates: MatchCandidate[] = ["1", "2"].map((suffix) => {
const userId = `user-${suffix}`;
return {
request: {
requestId: `request-${suffix}`,
userId,
intentSnapshot: { rawText: `intent-${suffix}` },
status: "matching" as const,
phase: "waiting" as const,
proactivePushEnabled: false,
activeRoundId: null,
optionsExpiresAt: null,
roomId: null,
inviteId: null,
createdAt: now,
updatedAt: now
},
userModel: createDefaultUserModel(userId),
matchingNarrative: `narrative-${suffix}`
};
});
const games: OfflineGame[] = [{
id: "game-story-table",
name: "Story table",
description: "A guided conversation",
minPlayers: 2,
maxPlayers: 6,
intentTags: [],
traits: [],
requirements: [],
instructions: []
}, {
id: "game-walk",
name: "Guided walk",
description: "A guided walk",
minPlayers: 2,
maxPlayers: 6,
intentTags: [],
traits: [],
requirements: [],
instructions: []
}];

const proposal = await hostedWithSearch().proposeMatchRound(candidates, games);

const payload = JSON.parse(requestBodies[0]!) as {
response_format: { type: string };
messages: Array<{ content: string }>;
};
expect(payload.response_format.type).toBe("json_object");
expect(payload.messages[1]?.content).toContain("json");
expect(proposal?.drafts[0]?.targetPlayers).toBe(2);
});

it("uses the only compatible activity for an exact two-person pool", async () => {
const now = new Date().toISOString();
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const candidates: MatchCandidate[] = ["1", "2"].map((suffix) => {
const userId = `user-${suffix}`;
return {
request: {
requestId: `request-${suffix}`,
userId,
intentSnapshot: { rawText: `intent-${suffix}` },
status: "matching" as const,
phase: "waiting" as const,
proactivePushEnabled: false,
activeRoundId: null,
optionsExpiresAt: null,
roomId: null,
inviteId: null,
createdAt: now,
updatedAt: now
},
userModel: createDefaultUserModel(userId),
matchingNarrative: `narrative-${suffix}`
};
});
const games: OfflineGame[] = [{
id: "game-story-table",
name: "Story table",
description: "A guided conversation",
minPlayers: 2,
maxPlayers: 6,
intentTags: [],
traits: [],
requirements: [],
instructions: []
}, {
id: "game-group-only",
name: "Group activity",
description: "Requires a larger group",
minPlayers: 4,
maxPlayers: 8,
intentTags: [],
traits: [],
requirements: [],
instructions: []
}];

const proposal = await hostedWithSearch().proposeMatchRound(candidates, games);

expect(fetchMock).not.toHaveBeenCalled();
expect(proposal?.drafts[0]).toMatchObject({
offlineGameId: "game-story-table",
targetPlayers: 2,
candidateRequestIds: ["request-1", "request-2"]
});
});
});

function agentContext(): AgentContext {
return buildAgentContext([], createDefaultUserModel("u1"));
}
Expand Down Expand Up @@ -1015,17 +1143,19 @@ describe("hosted Agent product-event composition", () => {
.rejects.toThrow("候选文案没有覆盖全部选项");
});

it("rejects option previews on non-candidate product events", async () => {
it("drops option previews on non-candidate product events", async () => {
const invalid = {
content: "这次匹配已经结束。",
optionPreviews: [{ optionNumber: 1, text: "不应出现的候选" }]
};
stubChatResponses(invalid, invalid);

await expect(hostedWithSearch().composeProductMessage(agentContext(), {
const result = await hostedWithSearch().composeProductMessage(agentContext(), {
kind: "match_expired",
facts: { reason: "selection_timeout", canRematch: true, rematchRequiresExplicitUserRequest: true }
})).rejects.toThrow("非候选事件不能返回 optionPreviews");
});

expect(result.optionPreviews).toEqual([]);
});

it("requires invitation and confirmation cards to omit the right border", async () => {
Expand Down
74 changes: 69 additions & 5 deletions packages/intelligence/src/hosted-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,32 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function normalizeMatchRoundProposalOutput(value: unknown): unknown {
if (!isRecord(value) || !Array.isArray(value.drafts)) return value;
return {
...value,
drafts: value.drafts.map((draft) => {
if (!isRecord(draft) || !Array.isArray(draft.candidateRequestIds)) return draft;
const candidateCount = new Set(
draft.candidateRequestIds.filter((requestId): requestId is string => typeof requestId === "string")
).size;
if (Number.isInteger(draft.targetPlayers)) return draft;
const arrayValue = Array.isArray(draft.targetPlayers) && draft.targetPlayers.length === 1
? draft.targetPlayers[0]
: undefined;
const numericValue = typeof arrayValue === "number"
? arrayValue
: typeof draft.targetPlayers === "string"
? Number(draft.targetPlayers)
: undefined;
return {
...draft,
targetPlayers: Number.isInteger(numericValue) ? numericValue : candidateCount
};
})
};
}

function normalizeTextValue(value: unknown, joiner = "\n\n"): unknown {
if (typeof value === "string") return value;
if (Array.isArray(value)) {
Expand Down Expand Up @@ -478,13 +504,17 @@ export class HostedLlmIntelligence implements AgentIntelligence, MatchmakingInte
temperature = 0.3,
stage = "unknown"
): Promise<unknown> {
const jsonSystem = `${system}\nReturn only a valid json object.`;
const jsonContent = Array.isArray(content)
? [{ type: "text", text: "Return only a valid json object." }, ...content]
: `Return only a valid json object.\n${typeof content === "string" ? content : JSON.stringify(content)}`;
const requestBody = JSON.stringify({
model,
temperature,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: system },
{ role: "user", content }
{ role: "system", content: jsonSystem },
{ role: "user", content: jsonContent }
]
});
const startedAt = Date.now();
Expand Down Expand Up @@ -1155,6 +1185,12 @@ export class HostedLlmIntelligence implements AgentIntelligence, MatchmakingInte
.map((option) => Number(option.optionNumber))
.filter((number) => Number.isInteger(number) && number >= 1 && number <= 3)
: [];
const normalizeProductMessage = (candidate: unknown): unknown => {
const normalized = normalizeReplyCandidate(candidate);
return event.kind !== "match_options" && isRecord(normalized)
? { ...normalized, optionPreviews: [] }
: normalized;
};
const draft = await this.parseOrRepair(
agentProductMessageSchema,
await this.chatJson(
Expand Down Expand Up @@ -1191,7 +1227,7 @@ export class HostedLlmIntelligence implements AgentIntelligence, MatchmakingInte
),
"只使用输入事实,返回可直接发送的个性化 content;match_options 的 optionPreviews 编号必须与输入完全一致。",
{ eventKind: event.kind, expectedOptionNumbers },
{ stage: "agent_event.draft", normalize: normalizeReplyCandidate }
{ stage: "agent_event.draft", normalize: normalizeProductMessage }
);
const result = await this.parseOrRepair(
agentProductMessageSchema,
Expand Down Expand Up @@ -1220,7 +1256,7 @@ export class HostedLlmIntelligence implements AgentIntelligence, MatchmakingInte
),
"发布前移除无事实依据的内容;候选编号必须完整且唯一,非候选事件 optionPreviews=[]。",
{ eventKind: event.kind, expectedOptionNumbers },
{ stage: "agent_event.verification", normalize: normalizeReplyCandidate }
{ stage: "agent_event.verification", normalize: normalizeProductMessage }
);
if (event.kind === "match_options") {
const actual = result.optionPreviews.map((option) => option.optionNumber).sort();
Expand Down Expand Up @@ -1523,6 +1559,29 @@ export class HostedLlmIntelligence implements AgentIntelligence, MatchmakingInte

async proposeMatchRound(candidates: MatchCandidate[], games: OfflineGame[]): Promise<MatchRoundProposal | null> {
if (candidates.length < Math.min(...games.map((game) => game.minPlayers))) return null;
const eligibleCandidates = candidates.filter(({ request }) =>
request.status === "matching" && ["waiting", "watching"].includes(request.phase ?? "waiting")
);
const compatibleGames = games.filter((game) =>
eligibleCandidates.length >= game.minPlayers && eligibleCandidates.length <= game.maxPlayers
);
if (eligibleCandidates.length === 2 && compatibleGames.length === 1) {
const game = compatibleGames[0]!;
const requestIds = eligibleCandidates.map(({ request }) => request.requestId);
return matchRoundProposalSchema.parse({
drafts: [{
tempDraftId: "two-person-draft",
offlineGameId: game.id,
targetPlayers: 2,
candidateRequestIds: requestIds,
rationale: `${game.name} supports a guided two-person interaction for the only currently eligible pair.`
}],
userOptions: requestIds.map((requestId) => ({
requestId,
tempDraftIds: ["two-person-draft"]
}))
});
}
const input = {
candidates: candidates.slice(0, 24).map(({ request, userModel, matchingNarrative, socialHooks, matchingPriority }) => ({
requestId: request.requestId,
Expand Down Expand Up @@ -1562,7 +1621,12 @@ export class HostedLlmIntelligence implements AgentIntelligence, MatchmakingInte
result,
"只输出 drafts 和 userOptions;所有 ID 必须来自输入。",
input,
{ stage: "match_round.proposal", model: this.options.textModel, temperature: 0.1 }
{
stage: "match_round.proposal",
model: this.options.textModel,
temperature: 0.1,
normalize: normalizeMatchRoundProposalOutput
}
);
}

Expand Down
Loading