feat: extract groups from message logs
This commit is contained in:
@@ -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 `<message>` 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_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `<message>` 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_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\<hash>` 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 |
|
| `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas plus `zyl\importal\<hash>` 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
|
## Stage C entry rule
|
||||||
|
|||||||
@@ -1103,7 +1103,7 @@ Evidence precheck result:
|
|||||||
- Search query matches group JID/display name case-insensitively.
|
- Search query matches group JID/display name case-insensitively.
|
||||||
- This loop does not register `isphere_search_groups`; registration comes after source behavior passes tests.
|
- 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:
|
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.
|
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.
|
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:
|
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
|
## 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.
|
- 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.
|
||||||
|
|||||||
81
internal/isphere/groups.go
Normal file
81
internal/isphere/groups.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
22
internal/isphere/groups_test.go
Normal file
22
internal/isphere/groups_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user