From 4502a05bb4a3226b6f4d212b09c49690522abbaa Mon Sep 17 00:00:00 2001 From: zhaoyilun Date: Thu, 9 Jul 2026 23:42:56 +0800 Subject: [PATCH] feat: extract groups from message logs --- .../capability-source-matrix.md | 2 +- ...tage-c-log-backed-receive-messages-loop.md | 71 +++++++++++++++- internal/isphere/groups.go | 81 +++++++++++++++++++ internal/isphere/groups_test.go | 22 +++++ 4 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 internal/isphere/groups.go create mode 100644 internal/isphere/groups_test.go diff --git a/docs/source-discovery/capability-source-matrix.md b/docs/source-discovery/capability-source-matrix.md index 85d0f29..0331227 100644 --- a/docs/source-discovery/capability-source-matrix.md +++ b/docs/source-discovery/capability-source-matrix.md @@ -19,7 +19,7 @@ Schema notes: `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md` | --- | --- | --- | --- | --- | --- | | `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `` stanzas via `internal/isphere.EncryptedPacketLogSource` | decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces; wrapped `MsgLib.db` after DB wrapper is solved | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | C5 env loader wired through ISPHERE_PACKET_LOG_FILE; default source remains empty when unset | fixture-backed configured-source operator smoke | | `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages | decrypted roster/contact stanzas from `Smark.SendReceive`; `MsgLib.db` contact/conversation tables after DB wrapper is solved; UIA helper source if display names are missing | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#log-decryption-result` | C8 MCP tool registered and verify-go-mcp covers default/configured contact smoke; JID-only display/account fallback | enrich display names from roster/db/uia evidence | -| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs | `MsgLib.db` group/conversation tables after DB wrapper is solved; UIA helper source if group display names are missing | C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C9 evidence confirmed; display names still unavailable | implement group candidate extraction from normalized messages | +| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs | `MsgLib.db` group/conversation tables after DB wrapper is solved; UIA helper source if group display names are missing | C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C10 source extraction unit-verified with JID-only display fallback | register MCP tool from C10 source | | `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas plus `zyl\importal\` cache mapping | decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | candidate pending cache-reference mapping | fourth | ## Stage C entry rule diff --git a/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md b/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md index 558bbb3..1e858fb 100644 --- a/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md +++ b/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md @@ -1103,7 +1103,7 @@ Evidence precheck result: - Search query matches group JID/display name case-insensitively. - This loop does not register `isphere_search_groups`; registration comes after source behavior passes tests. -- [ ] **Step 1: Write failing group extraction test** +- [x] **Step 1: Write failing group extraction test** Create `internal/isphere/groups_test.go` and run: @@ -1113,11 +1113,11 @@ go test ./internal/isphere -run TestSearchGroupsFromMessagesMatchesGroupchatJIDs Expected initial failure: group search types/functions do not exist. -- [ ] **Step 2: Implement group extraction/search** +- [x] **Step 2: Implement group extraction/search** Create `internal/isphere/groups.go` with minimal pure Go logic over normalized messages and synthetic groupchat fixtures. -- [ ] **Step 3: Run full verification and rewrite Loop C11** +- [x] **Step 3: Run full verification and rewrite Loop C11** Run: @@ -1132,6 +1132,71 @@ Expected: all exit 0. Remove any generated root `isphere-mcp.exe` after build. R --- +## Loop C10 Result + +Implemented and verified in this loop: + +- `internal/isphere.SearchGroupsFromMessages(messages, query)` extracts unique group candidates from normalized messages with `ConversationType == "groupchat"` or conference/MUC/group JID markers. +- Groups currently use the group bare JID as `GroupID` and `DisplayName`; `Source` is `local_readonly`, `Confidence` is `0.6`, and `RawRef` is `groupchat_jid` until richer group roster/display-name evidence is validated. +- Query matching is case-insensitive over group JID/display name and `Limit` truncates deterministic sorted results. +- Verification completed for C10 with the loop-specific test; full verification is run again before commit. + +--- + +## Loop C11: Register `isphere_search_groups` MCP tool + +**Goal:** Register the group search MCP tool on top of the C10 group extraction behavior while keeping display/member fields clearly marked as JID-derived/unknown. + +**Planned files:** +- Create: `internal/tools/isphere_groups.go` +- Create: `internal/tools/isphere_groups_test.go` +- Modify: `internal/mcpserver/server.go` +- Modify: `internal/mcpserver/server_test.go` +- Modify: `scripts/verify-go-mcp.ps1` +- Modify: `docs/go-mcp-runbook.md` +- Modify: `docs/current-status-card.md` +- Modify: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md` + +**Planned interface:** +- Tool name: `isphere_search_groups`. +- Input: `query`, `limit`. +- Source: group extraction over the same env-configured message source used by receive/contact tools. +- Output shape: `ok`, `groups`, `next_cursor`, `audit` minimal fields aligned with `docs/mcp-core-tools-contract.md`. +- Default empty source returns `ok: true`, `groups: []`. + +- [ ] **Step 1: Write failing tool test** + +Create `internal/tools/isphere_groups_test.go` and run: + +```powershell +go test ./internal/tools -run TestISphereSearchGroupsToolReturnsJIDGroups -v +``` + +Expected initial failure: group tool registration does not exist. + +- [ ] **Step 2: Implement group tool registration** + +Register `isphere_search_groups` with source injection for tests. Use C10 source behavior and return a contract-shaped map with minimal audit metadata. + +- [ ] **Step 3: Wire server and verification** + +Register group search in server construction. Update server tool-count tests and `scripts/verify-go-mcp.ps1` to expect seven tools and call `isphere_search_groups` against default empty source and synthetic configured groupchat fixture. + +- [ ] **Step 4: Run full verification and commit** + +Run: + +```powershell +git diff --check +go test ./... +go build ./cmd/isphere-mcp +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-go-mcp.ps1 +``` + +Expected: all exit 0. Remove any generated root `isphere-mcp.exe` after build. Commit exact C11 paths only and rewrite the next loop from actual results. + +--- + ## Self-Review - Spec coverage: the plan follows the user's cyclic requirement: plan first, implement one loop at a time, update the next loop after each result, and ask only on blockers. diff --git a/internal/isphere/groups.go b/internal/isphere/groups.go new file mode 100644 index 0000000..cf025fc --- /dev/null +++ b/internal/isphere/groups.go @@ -0,0 +1,81 @@ +package isphere + +import ( + "sort" + "strings" +) + +type Group struct { + GroupID string + DisplayName string + Source string + Confidence float64 + RawRef string +} + +type SearchGroupsQuery struct { + Query string + Limit int +} + +type SearchGroupsResult struct { + Groups []Group +} + +func SearchGroupsFromMessages(messages []Message, query SearchGroupsQuery) SearchGroupsResult { + unique := map[string]Group{} + for _, message := range messages { + if strings.EqualFold(message.ConversationType, "groupchat") { + addMessageGroup(unique, message.SenderID) + addMessageGroup(unique, message.ReceiverID) + continue + } + if isLikelyGroupJID(message.SenderID) { + addMessageGroup(unique, message.SenderID) + } + if isLikelyGroupJID(message.ReceiverID) { + addMessageGroup(unique, message.ReceiverID) + } + } + + queryText := strings.ToLower(strings.TrimSpace(query.Query)) + ids := make([]string, 0, len(unique)) + for id := range unique { + if queryText == "" || strings.Contains(strings.ToLower(id), queryText) || strings.Contains(strings.ToLower(unique[id].DisplayName), queryText) { + ids = append(ids, id) + } + } + sort.Strings(ids) + + limit := query.Limit + if limit <= 0 || limit > len(ids) { + limit = len(ids) + } + groups := make([]Group, 0, limit) + for _, id := range ids[:limit] { + groups = append(groups, unique[id]) + } + return SearchGroupsResult{Groups: groups} +} + +func addMessageGroup(unique map[string]Group, id string) { + trimmed := strings.TrimSpace(id) + if trimmed == "" || !isLikelyGroupJID(trimmed) { + return + } + if _, exists := unique[trimmed]; exists { + return + } + unique[trimmed] = Group{ + GroupID: trimmed, + DisplayName: trimmed, + Source: "local_readonly", + Confidence: 0.6, + RawRef: "groupchat_jid", + } +} + +func isLikelyGroupJID(id string) bool { + lower := strings.ToLower(strings.TrimSpace(id)) + return strings.Contains(lower, "conference") || strings.Contains(lower, "muc") || strings.Contains(lower, "group") +} diff --git a/internal/isphere/groups_test.go b/internal/isphere/groups_test.go new file mode 100644 index 0000000..d0c232c --- /dev/null +++ b/internal/isphere/groups_test.go @@ -0,0 +1,22 @@ +package isphere + +import ( + "reflect" + "testing" +) + +func TestSearchGroupsFromMessagesMatchesGroupchatJIDs(t *testing.T) { + messages := []Message{ + {ID: "msg-1", ConversationType: "groupchat", SenderID: "project-room@conference.imopenfire1-lanzhou", ReceiverID: "user@imopenfire1-lanzhou"}, + {ID: "msg-2", ConversationType: "chat", SenderID: "alice@imopenfire1-lanzhou", ReceiverID: "bob@imopenfire1-lanzhou"}, + {ID: "msg-3", ConversationType: "groupchat", SenderID: "project-room@conference.imopenfire1-lanzhou", ReceiverID: "other@imopenfire1-lanzhou"}, + } + + got := SearchGroupsFromMessages(messages, SearchGroupsQuery{Query: "project", Limit: 10}) + want := SearchGroupsResult{Groups: []Group{ + {GroupID: "project-room@conference.imopenfire1-lanzhou", DisplayName: "project-room@conference.imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "groupchat_jid"}, + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("SearchGroupsFromMessages() = %#v, want %#v", got, want) + } +}