Skip to content

Commit 43d965e

Browse files
committed
Add frontend work
1 parent f4549a4 commit 43d965e

66 files changed

Lines changed: 7525 additions & 22 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

core/tests/test_entrypoints.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ def test_celery_app_schedules_source_quality_before_authority_recompute():
5151
beat_schedule["run-all-source-quality-recomputations-nightly"]["task"]
5252
== "core.tasks.run_all_source_quality_recomputations"
5353
)
54+
assert (
55+
beat_schedule["run-all-scheduled-newsletter-drafts-every-minute"]["task"]
56+
== "core.tasks.run_all_scheduled_newsletter_drafts"
57+
)
5458
assert (
5559
beat_schedule["run-all-authority-recomputations-nightly"]["task"]
5660
== "core.tasks.run_all_authority_recomputations"

entities/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ class EntityCandidateViewSet(ProjectOwnedQuerysetMixin, viewsets.ReadOnlyModelVi
180180
serializer_class = EntityCandidateSerializer
181181
queryset = EntityCandidate.objects.select_related(
182182
"project", "first_seen_in", "merged_into"
183-
)
183+
).prefetch_related("evidence")
184184

185185
def get_permissions(self):
186186
"""Allow all members to read candidates and contributors to resolve them."""

entities/serializers.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ class EntityCandidateSerializer(
120120
source="first_seen_in.title", read_only=True
121121
)
122122
merged_into_name = serializers.CharField(source="merged_into.name", read_only=True)
123+
evidence_count = serializers.SerializerMethodField()
124+
source_plugin_count = serializers.SerializerMethodField()
125+
source_plugins = serializers.SerializerMethodField()
126+
identity_surfaces = serializers.SerializerMethodField()
123127

124128
class Meta:
125129
model = EntityCandidate
@@ -133,6 +137,10 @@ class Meta:
133137
"occurrence_count",
134138
"cluster_key",
135139
"auto_promotion_blocked_reason",
140+
"evidence_count",
141+
"source_plugin_count",
142+
"source_plugins",
143+
"identity_surfaces",
136144
"status",
137145
"merged_into",
138146
"merged_into_name",
@@ -141,6 +149,46 @@ class Meta:
141149
]
142150
read_only_fields = fields
143151

152+
def _candidate_evidence(self, obj):
153+
"""Return prefetched evidence rows when available."""
154+
155+
evidence = getattr(obj, "prefetched_evidence", None)
156+
if evidence is None:
157+
evidence = obj.evidence.all()
158+
return evidence
159+
160+
def get_evidence_count(self, obj) -> int:
161+
"""Return the number of evidence rows attached to the candidate."""
162+
163+
return len(self._candidate_evidence(obj))
164+
165+
def get_source_plugin_count(self, obj) -> int:
166+
"""Return the number of unique source plugins backing the candidate."""
167+
168+
return len(self.get_source_plugins(obj))
169+
170+
def get_source_plugins(self, obj) -> list[str]:
171+
"""Return the unique source plugins seen in candidate evidence."""
172+
173+
return sorted(
174+
{
175+
evidence.source_plugin
176+
for evidence in self._candidate_evidence(obj)
177+
if evidence.source_plugin
178+
}
179+
)
180+
181+
def get_identity_surfaces(self, obj) -> list[str]:
182+
"""Return the unique identity surfaces hinted by candidate evidence."""
183+
184+
return sorted(
185+
{
186+
evidence.identity_surface
187+
for evidence in self._candidate_evidence(obj)
188+
if evidence.identity_surface
189+
}
190+
)
191+
144192

145193
class EntityCandidateMergeSerializer(
146194
ProjectScopedSerializerMixin, serializers.Serializer

entities/tests/test_api.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
Entity,
1313
EntityAuthoritySnapshot,
1414
EntityCandidate,
15+
EntityCandidateEvidence,
1516
EntityCandidateStatus,
1617
EntityIdentityClaim,
1718
EntityMention,
@@ -333,6 +334,15 @@ def test_entity_candidate_list_is_scoped_to_request_user_project(self):
333334
cluster_key="owner-candidate-abcd1234",
334335
auto_promotion_blocked_reason="needs_more_occurrences",
335336
)
337+
EntityCandidateEvidence.objects.create(
338+
candidate=owner_candidate,
339+
project=self.owner_project,
340+
content=self.owner_content,
341+
source_plugin="linkedin",
342+
context_excerpt="Owner Candidate was cited in the article.",
343+
identity_surface="linkedin",
344+
claim_url="https://www.linkedin.com/company/owner-candidate",
345+
)
336346
EntityCandidate.objects.create(
337347
project=self.other_project,
338348
name="Other Candidate",
@@ -355,6 +365,10 @@ def test_entity_candidate_list_is_scoped_to_request_user_project(self):
355365
response.json()[0]["auto_promotion_blocked_reason"],
356366
owner_candidate.auto_promotion_blocked_reason,
357367
)
368+
self.assertEqual(response.json()[0]["evidence_count"], 1)
369+
self.assertEqual(response.json()[0]["source_plugin_count"], 1)
370+
self.assertEqual(response.json()[0]["source_plugins"], ["linkedin"])
371+
self.assertEqual(response.json()[0]["identity_surfaces"], ["linkedin"])
358372

359373
def test_entity_candidate_accept_action_returns_updated_candidate(self):
360374
with patch("entities.extraction.queue_entity_identity_enrichment"):

frontend/src/app/admin/sources/page.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,43 @@ describe("filterNewsletterIntakes", () => {
280280
})
281281
})
282282

283+
describe("SourcesPage", () => {
284+
beforeEach(() => {
285+
const defaultProject = createProject()
286+
287+
getProjectsMock.mockReset()
288+
getProjectSourceConfigsMock.mockReset()
289+
getProjectIngestionRunsMock.mockReset()
290+
getProjectIntakeAllowlistMock.mockReset()
291+
getProjectNewsletterIntakesMock.mockReset()
292+
getProjectBlueskyCredentialsMock.mockReset()
293+
getProjectLinkedInCredentialsMock.mockReset()
294+
getProjectMastodonCredentialsMock.mockReset()
295+
selectProjectMock.mockReset()
296+
297+
getProjectsMock.mockResolvedValue([defaultProject])
298+
getProjectSourceConfigsMock.mockResolvedValue([createSourceConfig()])
299+
getProjectIngestionRunsMock.mockResolvedValue([createIngestionRun()])
300+
getProjectIntakeAllowlistMock.mockResolvedValue([createAllowlistEntry()])
301+
getProjectNewsletterIntakesMock.mockResolvedValue([createNewsletterIntake()])
302+
getProjectBlueskyCredentialsMock.mockResolvedValue([createBlueskyCredentials()])
303+
getProjectLinkedInCredentialsMock.mockResolvedValue([
304+
createLinkedInCredentials({ expires_at: "2026-05-30T10:00:00Z" }),
305+
])
306+
getProjectMastodonCredentialsMock.mockResolvedValue([createMastodonCredentials()])
307+
selectProjectMock.mockImplementation((projects: Project[]) => projects[0] ?? null)
308+
})
309+
310+
it("renders the LinkedIn quick-add form alongside the OAuth panel", async () => {
311+
await renderSourcesPage({ project: "1" })
312+
313+
expect(screen.getByText("OAuth authorization")).toBeInTheDocument()
314+
expect(screen.getByRole("button", { name: "Add LinkedIn source" })).toBeInTheDocument()
315+
expect(screen.getByText("Surface type")).toBeInTheDocument()
316+
expect(screen.getByText("Quick config shapes")).toBeInTheDocument()
317+
})
318+
})
319+
283320
describe("SourcesPage", () => {
284321
beforeEach(() => {
285322
const defaultProject = createProject()

frontend/src/app/admin/sources/page.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,87 @@ export default async function SourcesPage({ searchParams }: SourcesPageProps) {
830830
Verify LinkedIn credentials
831831
</button>
832832
</form>
833+
834+
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
835+
<form
836+
action={`/api/projects/${selectedProject.id}/linkedin-source-configs`}
837+
className="space-y-4 rounded-2xl border border-border/10 bg-muted/45 p-4"
838+
method="POST"
839+
>
840+
<input
841+
type="hidden"
842+
name="redirectTo"
843+
value={`/admin/sources?project=${selectedProject.id}`}
844+
/>
845+
<div className="space-y-1">
846+
<p className="m-0 text-sm font-semibold uppercase tracking-[0.18em] text-muted">
847+
Add LinkedIn source
848+
</p>
849+
<p className="m-0 text-sm leading-6 text-muted">
850+
Create a project-scoped LinkedIn source without hand-writing config JSON.
851+
</p>
852+
</div>
853+
<label className="grid gap-2">
854+
<span className="text-sm font-medium text-foreground">Surface type</span>
855+
<select
856+
className="w-full rounded-2xl border border-border/12 bg-card px-4 py-3 text-foreground outline-none transition focus:border-primary/40 focus:ring-2 focus:ring-primary/15"
857+
defaultValue="organization"
858+
name="surface"
859+
>
860+
<option value="organization">Organization page</option>
861+
<option value="person">Person feed</option>
862+
<option value="newsletter">Newsletter feed</option>
863+
</select>
864+
</label>
865+
<label className="grid gap-2">
866+
<span className="text-sm font-medium text-foreground">URN</span>
867+
<input
868+
className="w-full rounded-2xl border border-border/12 bg-card px-4 py-3 text-foreground outline-none transition focus:border-primary/40 focus:ring-2 focus:ring-primary/15"
869+
name="urn"
870+
placeholder="urn:li:organization:1337"
871+
required
872+
/>
873+
</label>
874+
<div className="grid gap-4 sm:grid-cols-2">
875+
<label className="grid gap-2">
876+
<span className="text-sm font-medium text-foreground">Max posts per fetch</span>
877+
<input
878+
className="w-full rounded-2xl border border-border/12 bg-card px-4 py-3 text-foreground outline-none transition focus:border-primary/40 focus:ring-2 focus:ring-primary/15"
879+
defaultValue="50"
880+
min="1"
881+
name="max_posts_per_fetch"
882+
type="number"
883+
/>
884+
</label>
885+
<label className="grid gap-2">
886+
<span className="text-sm font-medium text-foreground">Include reshares</span>
887+
<select
888+
className="w-full rounded-2xl border border-border/12 bg-card px-4 py-3 text-foreground outline-none transition focus:border-primary/40 focus:ring-2 focus:ring-primary/15"
889+
defaultValue="false"
890+
name="include_reshares"
891+
>
892+
<option value="false">No</option>
893+
<option value="true">Yes</option>
894+
</select>
895+
</label>
896+
</div>
897+
<button className="inline-flex min-h-11 items-center justify-center rounded-full border border-border/12 bg-transparent px-4 py-3 text-sm font-medium text-foreground transition hover:bg-muted/50" type="submit">
898+
Add LinkedIn source
899+
</button>
900+
</form>
901+
902+
<section className="space-y-2 rounded-2xl border border-border/10 bg-muted/45 p-4">
903+
<p className="m-0 text-sm font-semibold uppercase tracking-[0.18em] text-muted">
904+
Quick config shapes
905+
</p>
906+
<p className="m-0 text-sm leading-6 text-muted">
907+
Organization and newsletter sources use <span className="font-mono text-foreground">max_posts_per_fetch</span>. Person sources use <span className="font-mono text-foreground">include_reshares</span>.
908+
</p>
909+
<p className="m-0 text-sm leading-6 text-muted">
910+
The generic source editor below still works for advanced payloads, but most projects should be able to onboard LinkedIn surfaces from this form alone.
911+
</p>
912+
</section>
913+
</div>
833914
</article>
834915

835916
<article className="space-y-4 rounded-3xl border border-border/12 bg-card/85 p-5 shadow-panel backdrop-blur-xl">

frontend/src/app/api/entity-candidates/[id]/route.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ function createCandidate(overrides: Partial<EntityCandidate> = {}): EntityCandid
3535
first_seen_in: 21,
3636
first_seen_title: "River Labs launches hosted platform",
3737
occurrence_count: 2,
38+
cluster_key: "cluster-9",
39+
auto_promotion_blocked_reason: "needs_more_occurrences",
40+
evidence_count: 2,
41+
source_plugin_count: 2,
42+
source_plugins: ["linkedin", "rss"],
43+
identity_surfaces: ["linkedin"],
3844
status: "pending",
3945
merged_into: null,
4046
merged_into_name: "",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest"
2+
3+
import {
4+
createProjectConfig,
5+
recomputeProjectConfigAuthority,
6+
updateProjectConfig,
7+
} from "@/lib/api"
8+
9+
import { POST } from "./route"
10+
11+
vi.mock("@/lib/api", () => ({
12+
createProjectConfig: vi.fn(),
13+
recomputeProjectConfigAuthority: vi.fn(),
14+
updateProjectConfig: vi.fn(),
15+
}))
16+
17+
describe("POST /api/projects/[id]/authority-settings", () => {
18+
beforeEach(() => {
19+
vi.clearAllMocks()
20+
})
21+
22+
function buildFormData() {
23+
const formData = new FormData()
24+
formData.set("draft_schedule_cron", "")
25+
formData.set("authority_weight_mention", "0.2")
26+
formData.set("authority_weight_engagement", "0.15")
27+
formData.set("authority_weight_recency", "0.15")
28+
formData.set("authority_weight_source_quality", "0.15")
29+
formData.set("authority_weight_cross_newsletter", "0.2")
30+
formData.set("authority_weight_feedback", "0.1")
31+
formData.set("authority_weight_duplicate", "0.05")
32+
formData.set("upvote_authority_weight", "0.05")
33+
formData.set("downvote_authority_weight", "-0.05")
34+
formData.set("authority_decay_rate", "0.9")
35+
return formData
36+
}
37+
38+
it("creates a config and returns JSON for a save request", async () => {
39+
vi.mocked(createProjectConfig).mockResolvedValue({ id: 7 } as never)
40+
41+
const response = await POST(
42+
new Request("http://localhost/api/projects/4/authority-settings?mode=json", {
43+
method: "POST",
44+
body: buildFormData(),
45+
}),
46+
{
47+
params: Promise.resolve({ id: "4" }),
48+
},
49+
)
50+
51+
expect(createProjectConfig).toHaveBeenCalledWith(
52+
4,
53+
expect.objectContaining({ authority_weight_engagement: 0.15 }),
54+
)
55+
expect(updateProjectConfig).not.toHaveBeenCalled()
56+
expect(response.status).toBe(200)
57+
await expect(response.json()).resolves.toEqual({
58+
configId: 7,
59+
message: "Authority weights saved.",
60+
})
61+
})
62+
63+
it("updates and recomputes when requested", async () => {
64+
vi.mocked(updateProjectConfig).mockResolvedValue({ id: 9 } as never)
65+
vi.mocked(recomputeProjectConfigAuthority).mockResolvedValue({
66+
status: "completed",
67+
project_id: 4,
68+
config_id: 9,
69+
})
70+
71+
const formData = buildFormData()
72+
formData.set("configId", "9")
73+
formData.set("intent", "save_and_recompute")
74+
75+
const response = await POST(
76+
new Request("http://localhost/api/projects/4/authority-settings?mode=json", {
77+
method: "POST",
78+
body: formData,
79+
}),
80+
{
81+
params: Promise.resolve({ id: "4" }),
82+
},
83+
)
84+
85+
expect(updateProjectConfig).toHaveBeenCalledWith(
86+
4,
87+
9,
88+
expect.objectContaining({ authority_weight_source_quality: 0.15 }),
89+
)
90+
expect(recomputeProjectConfigAuthority).toHaveBeenCalledWith(4, 9)
91+
expect(response.status).toBe(200)
92+
await expect(response.json()).resolves.toEqual({
93+
configId: 9,
94+
message: "Authority weights saved and recomputed.",
95+
})
96+
})
97+
})

0 commit comments

Comments
 (0)