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
27 changes: 19 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

TOMEET 是一个可以长期对话的社交 Agent。

Agent 通过用户主动提供的文本、图片、短录音和持续对话认识用户。当用户想社交时,系统使用 LLM 匹配合适的 3–10 人,并从人工策划的线下游戏中选择合适的游戏。活动结束后,用户向 Agent 反馈感受,Agent 据此持续对齐用户意图。
Agent 通过用户主动提供的文本、图片、短录音和持续对话认识用户。当用户想社交时,系统先使用 LLM 贪心选择当前最高匹配的一位用户;双方接受后建立房间,再逐位邀请新用户直至满员或成员明确停止匹配。活动结束后,用户向 Agent 反馈感受,Agent 据此持续对齐用户意图。

## 当前实现

Expand Down Expand Up @@ -176,8 +176,8 @@ flowchart LR
Conversation["与 Agent 长期对话"]
Understanding["持续认识用户"]
Intent["用户表达社交意图"]
Match["LLM 匹配 3–10 人和线下游戏"]
Room["生成匹配房间"]
Match["LLM 贪心选择下一位用户"]
Room["双边接受后建房并逐位扩充"]
Offline["线下参与游戏"]
Feedback["向 Agent 反馈感受"]
Alignment["更新用户理解和意图"]
Expand Down Expand Up @@ -262,13 +262,13 @@ LLM 匹配任务只读取受治理的自然语言输入:

原始 profile、详细记忆、多模态原文、兴趣标签、`intentTags`、`traits`、性格类型、人口属性、关键词计数和标签分数都不会进入匹配模型输入。游戏目录可以保留运营元数据,但匹配只看到自然语言体验说明与硬性人数条件。

LLM 输出结构化 `MatchDecision`:
初始 LLM 输出结构化 `MatchDecision`:

- 3–10 名成员。
- 触发用户和当前最高匹配的另一位用户,共 2 人。
- 选中的线下游戏。
- 匹配判断摘要。

系统校验成员仍在等待、没有重复分配、人数符合要求、游戏适合该人数后创建房间。
系统先创建双边邀请,双方接受后才创建房间。房间处于 `active` 时,每次只为当前最高匹配的一位等待用户创建入房邀请;接受后原子加入并继续下一轮。达到 `capacity` 自动变为 `full`,任一成员明确说“停止匹配”等同义指令后变为 `stopped`。

### Offline Game Catalog

Expand All @@ -286,9 +286,10 @@ LLM 输出结构化 `MatchDecision`:

匹配房间只保存:

- 3–10 名成员。
- 2–10 名成员。
- 选中的线下游戏。
- 成员确认状态。
- 持续匹配状态和房间人数上限。
- 活动完成状态。

房间不承载在线游戏过程。
Expand Down Expand Up @@ -369,7 +370,9 @@ export interface MatchRequest {
requestId: string;
userId: string;
intentSnapshot: Record<string, unknown>;
status: "matching" | "matched" | "cancelled" | "expired";
status: "matching" | "invited" | "matched" | "cancelled" | "expired";
roomId: string | null;
inviteId: string | null;
}

export interface MatchDecision {
Expand All @@ -383,6 +386,8 @@ export interface MatchRoom {
memberIds: string[];
offlineGameId: string;
status: "confirming" | "confirmed" | "completed";
matchingStatus: "active" | "stopped" | "full";
capacity: number;
}

export interface PostEventFeedback {
Expand All @@ -404,9 +409,13 @@ POST /agent/multimodal-inputs
POST /match-requests
GET /match-requests/:id
POST /match-requests/:id/cancel
GET /match-invites/:id
POST /match-invites/:id/accept
POST /match-invites/:id/decline

GET /rooms/:id
POST /rooms/:id/confirm
POST /rooms/:id/stop-match
POST /rooms/:id/complete
POST /rooms/:id/feedback
```
Expand Down Expand Up @@ -521,6 +530,8 @@ ADVENTUREX_MATCHING_V1
- 用户能通过 Agent 收到并自然语言选择 1–3 个现场活动候选。
- 系统只用明确接受的候选原子创建 3–10 人确认房间,并支持合适的开放局补位。
- 成员、集合信息和招募状态变化能通过幂等 Agent 消息主动通知。
- LLM 能按贪心机制逐位选择候选用户并选择一款已有线下游戏。
- 系统能在双边接受后建房,持续邀请新成员,并在满员或收到停止指令后终止匹配。
- 活动结束后,用户能向 Agent 反馈感受和连接结果。
- 反馈能更新用户意图,并影响下一次匹配和游戏选择。

Expand Down
19 changes: 14 additions & 5 deletions apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,8 +475,14 @@ describe("TOMEET core flow", () => {

const requestId = socialResult.actions[0].matchRequest.requestId as string;
const requestResponse = await app.inject({ method: "GET", url: `/match-requests/${requestId}` });
const roomId = requestResponse.json().matchRequest.roomId as string;
expect(roomId).toBeTruthy();
expect(requestResponse.json().matchRequest.status).toBe("invited");
expect(requestResponse.json().matchRequest.roomId).toBeNull();
expect(requestResponse.json().matchRequest.inviteId).toBeTruthy();

const acceptResponse = await send("接受匹配");
expect(acceptResponse.json().job.result.actions[0].type).toBe("accept_match");
expect(acceptResponse.json().job.result.actions[0].room.status).toBe("confirmed");
const roomId = acceptResponse.json().job.result.actions[0].room.roomId as string;

const duplicateRoomMatch = await app.inject({
method: "POST",
Expand All @@ -486,10 +492,13 @@ describe("TOMEET core flow", () => {
expect(duplicateRoomMatch.statusCode).toBe(409);

const historyResponse = await app.inject({ method: "GET", url: `/agent/messages/${userId}` });
expect(historyResponse.json().messages.some((message: { content: string }) => message.content.includes("匹配已经完成"))).toBe(true);
expect(historyResponse.json().messages.some(
(message: { content: string }) => message.content.includes("房间已建立")
)).toBe(true);

const confirmResponse = await send("确认参加,没问题");
expect(confirmResponse.json().job.result.actions[0].room.status).toBe("confirmed");
const stopResponse = await send("停止匹配,不要再往房间里加人了");
expect(stopResponse.json().job.result.actions[0].type).toBe("stop_match");
expect(stopResponse.json().job.result.actions[0].room.matchingStatus).toBe("stopped");

const completeResponse = await send("活动已经结束了");
expect(completeResponse.json().job.result.actions[0].room.status).toBe("completed");
Expand Down
90 changes: 89 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,37 @@ export async function buildApp(options: BuildAppOptions) {
return scheduleAdventurexMatchRequest(options.store, matchRequest);
}

async function enqueueRoomContinuation(roomId: string, memberCount: number, reason: string): Promise<void> {
const room = await options.store.getRoom(roomId);
if (
!room
|| room.status === "completed"
|| room.matchingStatus !== "active"
|| room.members.length >= room.capacity
) return;
const job = await options.store.enqueueJob({
type: "matchmaking",
payload: { roomId },
idempotencyKey: `match:room:${roomId}:${memberCount}:${reason}`,
partitionKey: `room:${roomId}`
});
await runInline(job.id);
}

async function enqueueRequeuedRequests(requestIds: string[], reason: string): Promise<void> {
await Promise.all(requestIds.map(async (requestId) => {
const matchRequest = await options.store.getMatchRequest(requestId);
if (!matchRequest || matchRequest.status !== "matching") return;
const job = await options.store.enqueueJob({
type: "matchmaking",
payload: { requestId },
idempotencyKey: `match:${requestId}:${reason}`,
partitionKey: `user:${matchRequest.userId}`
});
await runInline(job.id);
}));
}

app.get("/health", { config: { rateLimit: false } }, async () => ({
status: "ok",
service: "tomeet-api",
Expand Down Expand Up @@ -655,7 +686,12 @@ export async function buildApp(options: BuildAppOptions) {
}
const currentJob = await runInline(job.id);
const latestRequest = await options.store.getMatchRequest(matchRequest.requestId);
return reply.code(latestRequest?.status === "matched" ? 201 : 202).send({ matchRequest: latestRequest, job: currentJob });
const invite = await options.store.getLatestMatchInviteForUser(input.userId);
return reply.code(latestRequest?.status === "matched" ? 201 : 202).send({
matchRequest: latestRequest,
invite,
job: currentJob
});
});

app.get("/match-requests/:id", async (request) => {
Expand Down Expand Up @@ -762,6 +798,49 @@ export async function buildApp(options: BuildAppOptions) {
return { room };
});

app.get("/match-invites/:id", async (request) => {
const { id } = z.object({ id: uuidSchema }).parse(request.params);
const invite = await options.store.getMatchInvite(id);
if (!invite) throw new StoreNotFoundError("匹配邀请不存在");
if (request.authUserId && !invite.participants.some(
(participant) => participant.userId === request.authUserId
)) {
throw new StoreNotFoundError("匹配邀请不存在");
}
return { invite };
});

app.post("/match-invites/:id/accept", async (request) => {
const { id } = z.object({ id: uuidSchema }).parse(request.params);
const { userId } = z.object({ userId: uuidSchema }).parse(request.body);
assertCurrentUser(request, userId);
const resolution = await options.store.acceptMatchInvite(id, userId);
if (resolution.room) {
await enqueueRoomContinuation(
resolution.room.roomId,
resolution.room.members.length,
`api-accepted:${id}`
);
}
return resolution;
});

app.post("/match-invites/:id/decline", async (request) => {
const { id } = z.object({ id: uuidSchema }).parse(request.params);
const { userId } = z.object({ userId: uuidSchema }).parse(request.body);
assertCurrentUser(request, userId);
const resolution = await options.store.declineMatchInvite(id, userId);
await enqueueRequeuedRequests(resolution.requeuedRequestIds, `api-declined:${id}`);
if (resolution.room) {
await enqueueRoomContinuation(
resolution.room.roomId,
resolution.room.members.length,
`api-declined:${id}`
);
}
return resolution;
});

async function getJob(request: FastifyRequest) {
const { id } = z.object({ id: uuidSchema }).parse(request.params);
const job = await options.store.getJob(id);
Expand Down Expand Up @@ -819,6 +898,15 @@ export async function buildApp(options: BuildAppOptions) {
};
});

app.post("/rooms/:id/stop-match", async (request) => {
const { id } = z.object({ id: uuidSchema }).parse(request.params);
const { userId } = z.object({ userId: uuidSchema }).parse(request.body);
assertCurrentUser(request, userId);
const stopped = await options.store.stopRoomMatching(id, userId);
await enqueueRequeuedRequests(stopped.requeuedRequestIds, `api-room-stopped:${id}`);
return stopped;
});

app.post("/rooms/:id/complete", async (request) => {
const { id } = z.object({ id: uuidSchema }).parse(request.params);
const room = await options.store.getRoom(id);
Expand Down
32 changes: 30 additions & 2 deletions apps/api/src/wechat-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,21 @@ describe("WeChat one-time QR onboarding", () => {
expect(response.statusCode).toBe(200);
}

for (const webUser of webUsers) {
const accepted = await app.inject({
method: "POST",
url: "/agent/messages",
headers: { authorization: `Bearer ${webUser.token}` },
payload: {
userId: webUser.userId,
displayName: webUser.displayName,
content: "接受匹配",
idempotencyKey: randomUUID()
}
});
expect(accepted.statusCode).toBe(200);
}

const wechatResponse = await app.inject({
method: "POST",
url: "/internal/agent/messages",
Expand All @@ -574,6 +589,19 @@ describe("WeChat one-time QR onboarding", () => {
});
expect(wechatResponse.statusCode).toBe(200);

const wechatAccepted = await app.inject({
method: "POST",
url: "/internal/agent/messages",
headers: { "x-tomeet-internal-token": internalApiToken },
payload: {
userId: wechatUserId,
displayName: "微信用户",
content: "接受邀请",
idempotencyKey: randomUUID()
}
});
expect(wechatAccepted.statusCode).toBe(200);

const allUserIds = [...webUsers.map((user) => user.userId), wechatUserId];
const rooms = await Promise.all(
allUserIds.map((userId) => store.getLatestRoomForUser(userId))
Expand All @@ -591,7 +619,7 @@ describe("WeChat one-time QR onboarding", () => {
});
expect(history.statusCode).toBe(200);
expect(history.json().messages.some(
(message: { content: string }) => message.content.includes("匹配已经完成")
(message: { content: string }) => message.content.includes("房间已建立")
)).toBe(true);
}

Expand All @@ -602,7 +630,7 @@ describe("WeChat one-time QR onboarding", () => {
});
expect(wechatHistory.statusCode).toBe(200);
expect(wechatHistory.json().messages.some(
(message: { content: string }) => message.content.includes("匹配已经完成")
(message: { content: string }) => message.content.includes("已接受邀请并进入房间")
)).toBe(true);
});

Expand Down
3 changes: 3 additions & 0 deletions apps/intelligence-worker/src/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ const room = {
recruitmentStatus: "closed" as const,
version: 0,
meetingPoint: null,
matchingStatus: "active" as const,
capacity: 6,
createdAt: new Date().toISOString(),
completedAt: null
};
Expand Down Expand Up @@ -104,6 +106,7 @@ const candidates = Array.from({ length: 5 }, (_, index) => {
status: "matching" as const,
proactivePushEnabled: false,
roomId: null,
inviteId: null,
createdAt: now,
updatedAt: now
},
Expand Down
24 changes: 20 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,15 @@ await supabase.storage
}
```

也可显式传入 `intent` 对象。没有明确社交意图、存在未结束房间时返回 `409`。启用 `ADVENTUREX_MATCHING_V1=true` 后,请求进入下一个 30 秒后台清算 tick;该 tick 不是用户等待倒计时。只有真实候选发送成功后才开始 90 秒选择窗口。
也可显式传入 `intent` 对象。没有明确社交意图、存在未结束房间时返回 `409`。

默认流程先按贪心机制选择当前最匹配的一位用户,创建双边邀请;双方接受后才创建房间。响应为 `{ "matchRequest": MatchRequest, "invite": MatchInvite | null, "job": LlmJob }`,等待或已发出邀请时返回 `202`,已进入房间时返回 `201`。

启用 `ADVENTUREX_MATCHING_V1=true` 后,请求进入下一个 30 秒后台清算 tick;该 tick 不是用户等待倒计时。只有真实候选发送成功后才开始 90 秒选择窗口。

### `GET /match-requests/:id`

返回 `{ "matchRequest": MatchRequest }`。`status` 为 `matching | matched | cancelled | expired`;其中 `expired` 表示候选窗口结束后本次请求未成局且没有保留主动推送授权。活跃请求另有 `phase=waiting | offered | selected | settling | push_consent | watching`,并通过 `proactivePushEnabled` 表示是否允许微信主动推送。匹配成功时 `roomId` 非空。
返回 `{ "matchRequest": MatchRequest }`。`status` 为 `matching | invited | matched | cancelled | expired`;`invited` 时 `inviteId` 非空,进入房间后 `roomId` 非空。`expired` 表示候选窗口结束后本次请求未成局且没有保留主动推送授权。活跃请求另有 `phase=waiting | offered | selected | settling | push_consent | watching`,并通过 `proactivePushEnabled` 表示是否允许微信主动推送。

### `GET /match-requests/:id/options`

Expand Down Expand Up @@ -316,9 +320,17 @@ await supabase.storage

该接口同时要求正常 Supabase Bearer 登录和 `ADVENTUREX_TEST_POOL_EMAIL` 邮箱白名单。未配置 `ADVENTUREX_TEST_POOL_ENABLED=true` 时返回 `503`,非白名单账号返回 `403`。测试用户只会进入所有者的 `adventurex-test:<owner>:<tick>` 隔离轮次。

### `POST /match-requests/:id/cancel`
### `GET /match-invites/:id`

仅邀请参与者可读取,返回 `{ "invite": MatchInvite }`。

### `POST /match-invites/:id/accept`

无请求体。只能取消 `matching` 状态,响应为 `{ "matchRequest": MatchRequest, "canRematch": true }`。
请求体为 `{ "userId": "UUID" }`。初始邀请需双方接受才会原子建房;入房邀请由候选用户接受后原子加入。返回 `{ "invite": MatchInvite, "room": MatchRoom | null, "requeuedRequestIds": string[] }`。

### `POST /match-invites/:id/decline`

请求体为 `{ "userId": "UUID" }`。初始邀请被拒绝时,拒绝方请求取消,另一方重新进入队列;入房邀请被拒绝时,房间保持持续匹配。

## 9. 房间与反馈

Expand Down Expand Up @@ -346,6 +358,10 @@ AdventureX 新流程中,用户对候选的选择已构成参加意愿,最终

成员行保留并标记为 `withdrawn`,房间 `version` 增加;有空位时重新开放招募。退出用户不会再次收到同一个开放局。剩余确认成员通过 Agent 消息收到一次幂等变化通知。响应包含 `canRematch=false`、最新 `matchRequest` 与 `interestState`:此前已授权主动推送时请求变为 `matching/watching`,否则变为 `cancelled`,且不会自动询问或启动重新匹配。

### `POST /rooms/:id/stop-match`

请求体为 `{ "userId": "UUID" }`。任一在房成员可停止继续加人;房间 `matchingStatus` 变为 `stopped`,待处理的入房邀请被取消,其候选请求重新入队。达到 `capacity` 时房间自动变为 `full`。

### `POST /rooms/:id/complete`

无请求体。当前登录用户必须是房间成员,且所有成员已确认。重复调用是幂等的。
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,13 @@ Agent 首轮只冻结回复草稿、当前意图、产品 action、记忆检索

匹配输入明确排除兴趣标签、`intentTags`、`traits`、性格分类、人口属性、关键词计数和标签分数。LLM 只能根据多模态材料形成的整体表达节奏、能量、关系距离和可能的线下互动流动做判断。

LLM 直接输出:
初始匹配时,LLM 直接输出:

- 3–10 名成员。
- 触发用户和当前最高匹配的另一位用户。
- 一款已有线下游戏。
- 匹配判断摘要。

API 在事务中校验请求仍然有效、成员没有重复分配、人数符合要求、游戏支持该人数,然后写入 `MatchRoom` 和 `RoomMembers`。
API 在事务中创建双边邀请,双方接受后才写入 2 人 `MatchRoom`。之后每轮只选择一名最高匹配的等待用户并创建入房邀请;接受后原子写入 `RoomMembers`。房间达到 `capacity` 后自动 `full`,或由任一成员通过语义 action `stop_match` 变为 `stopped`。

## 后台任务

Expand Down
Loading
Loading