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
4 changes: 4 additions & 0 deletions core/tests/test_entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ def test_celery_app_schedules_source_quality_before_authority_recompute():
beat_schedule["run-all-source-quality-recomputations-nightly"]["task"]
== "core.tasks.run_all_source_quality_recomputations"
)
assert (
beat_schedule["run-all-scheduled-newsletter-drafts-every-minute"]["task"]
== "core.tasks.run_all_scheduled_newsletter_drafts"
)
assert (
beat_schedule["run-all-authority-recomputations-nightly"]["task"]
== "core.tasks.run_all_authority_recomputations"
Expand Down
2 changes: 1 addition & 1 deletion entities/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ class EntityCandidateViewSet(ProjectOwnedQuerysetMixin, viewsets.ReadOnlyModelVi
serializer_class = EntityCandidateSerializer
queryset = EntityCandidate.objects.select_related(
"project", "first_seen_in", "merged_into"
)
).prefetch_related("evidence")

def get_permissions(self):
"""Allow all members to read candidates and contributors to resolve them."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ class Migration(migrations.Migration):
"indexes": [
models.Index(
fields=["candidate", "source_plugin"],
name="core_entitycand_candidate_source_idx",
name="core_entcand_candsrc_idx",
),
models.Index(
fields=["project", "created_at"],
name="core_entitycand_project_created_idx",
name="core_entcand_projtime_idx",
),
],
"constraints": [
Expand Down
4 changes: 2 additions & 2 deletions entities/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,11 +290,11 @@ class Meta:
indexes = [
models.Index(
fields=["candidate", "source_plugin"],
name="core_entitycand_candidate_source_idx",
name="core_entcand_candsrc_idx",
),
models.Index(
fields=["project", "created_at"],
name="core_entitycand_project_created_idx",
name="core_entcand_projtime_idx",
),
]

Expand Down
48 changes: 48 additions & 0 deletions entities/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ class EntityCandidateSerializer(
source="first_seen_in.title", read_only=True
)
merged_into_name = serializers.CharField(source="merged_into.name", read_only=True)
evidence_count = serializers.SerializerMethodField()
source_plugin_count = serializers.SerializerMethodField()
source_plugins = serializers.SerializerMethodField()
identity_surfaces = serializers.SerializerMethodField()

class Meta:
model = EntityCandidate
Expand All @@ -133,6 +137,10 @@ class Meta:
"occurrence_count",
"cluster_key",
"auto_promotion_blocked_reason",
"evidence_count",
"source_plugin_count",
"source_plugins",
"identity_surfaces",
"status",
"merged_into",
"merged_into_name",
Expand All @@ -141,6 +149,46 @@ class Meta:
]
read_only_fields = fields

def _candidate_evidence(self, obj):
"""Return prefetched evidence rows when available."""

evidence = getattr(obj, "prefetched_evidence", None)
if evidence is None:
evidence = obj.evidence.all()
return evidence

def get_evidence_count(self, obj) -> int:
"""Return the number of evidence rows attached to the candidate."""

return len(self._candidate_evidence(obj))

def get_source_plugin_count(self, obj) -> int:
"""Return the number of unique source plugins backing the candidate."""

return len(self.get_source_plugins(obj))

def get_source_plugins(self, obj) -> list[str]:
"""Return the unique source plugins seen in candidate evidence."""

return sorted(
{
evidence.source_plugin
for evidence in self._candidate_evidence(obj)
if evidence.source_plugin
}
)

def get_identity_surfaces(self, obj) -> list[str]:
"""Return the unique identity surfaces hinted by candidate evidence."""

return sorted(
{
evidence.identity_surface
for evidence in self._candidate_evidence(obj)
if evidence.identity_surface
}
)


class EntityCandidateMergeSerializer(
ProjectScopedSerializerMixin, serializers.Serializer
Expand Down
14 changes: 14 additions & 0 deletions entities/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
Entity,
EntityAuthoritySnapshot,
EntityCandidate,
EntityCandidateEvidence,
EntityCandidateStatus,
EntityIdentityClaim,
EntityMention,
Expand Down Expand Up @@ -333,6 +334,15 @@ def test_entity_candidate_list_is_scoped_to_request_user_project(self):
cluster_key="owner-candidate-abcd1234",
auto_promotion_blocked_reason="needs_more_occurrences",
)
EntityCandidateEvidence.objects.create(
candidate=owner_candidate,
project=self.owner_project,
content=self.owner_content,
source_plugin="linkedin",
context_excerpt="Owner Candidate was cited in the article.",
identity_surface="linkedin",
claim_url="https://www.linkedin.com/company/owner-candidate",
)
EntityCandidate.objects.create(
project=self.other_project,
name="Other Candidate",
Expand All @@ -355,6 +365,10 @@ def test_entity_candidate_list_is_scoped_to_request_user_project(self):
response.json()[0]["auto_promotion_blocked_reason"],
owner_candidate.auto_promotion_blocked_reason,
)
self.assertEqual(response.json()[0]["evidence_count"], 1)
self.assertEqual(response.json()[0]["source_plugin_count"], 1)
self.assertEqual(response.json()[0]["source_plugins"], ["linkedin"])
self.assertEqual(response.json()[0]["identity_surfaces"], ["linkedin"])

def test_entity_candidate_accept_action_returns_updated_candidate(self):
with patch("entities.extraction.queue_entity_identity_enrichment"):
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/app/admin/sources/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,43 @@ describe("filterNewsletterIntakes", () => {
})
})

describe("SourcesPage", () => {
beforeEach(() => {
const defaultProject = createProject()

getProjectsMock.mockReset()
getProjectSourceConfigsMock.mockReset()
getProjectIngestionRunsMock.mockReset()
getProjectIntakeAllowlistMock.mockReset()
getProjectNewsletterIntakesMock.mockReset()
getProjectBlueskyCredentialsMock.mockReset()
getProjectLinkedInCredentialsMock.mockReset()
getProjectMastodonCredentialsMock.mockReset()
selectProjectMock.mockReset()

getProjectsMock.mockResolvedValue([defaultProject])
getProjectSourceConfigsMock.mockResolvedValue([createSourceConfig()])
getProjectIngestionRunsMock.mockResolvedValue([createIngestionRun()])
getProjectIntakeAllowlistMock.mockResolvedValue([createAllowlistEntry()])
getProjectNewsletterIntakesMock.mockResolvedValue([createNewsletterIntake()])
getProjectBlueskyCredentialsMock.mockResolvedValue([createBlueskyCredentials()])
getProjectLinkedInCredentialsMock.mockResolvedValue([
createLinkedInCredentials({ expires_at: "2026-05-30T10:00:00Z" }),
])
getProjectMastodonCredentialsMock.mockResolvedValue([createMastodonCredentials()])
selectProjectMock.mockImplementation((projects: Project[]) => projects[0] ?? null)
})

it("renders the LinkedIn quick-add form alongside the OAuth panel", async () => {
await renderSourcesPage({ project: "1" })

expect(screen.getByText("OAuth authorization")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Add LinkedIn source" })).toBeInTheDocument()
expect(screen.getByText("Surface type")).toBeInTheDocument()
expect(screen.getByText("Quick config shapes")).toBeInTheDocument()
})
})

describe("SourcesPage", () => {
beforeEach(() => {
const defaultProject = createProject()
Expand Down
81 changes: 81 additions & 0 deletions frontend/src/app/admin/sources/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,87 @@ export default async function SourcesPage({ searchParams }: SourcesPageProps) {
Verify LinkedIn credentials
</button>
</form>

<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<form
action={`/api/projects/${selectedProject.id}/linkedin-source-configs`}
className="space-y-4 rounded-2xl border border-border/10 bg-muted/45 p-4"
method="POST"
>
<input
type="hidden"
name="redirectTo"
value={`/admin/sources?project=${selectedProject.id}`}
/>
<div className="space-y-1">
<p className="m-0 text-sm font-semibold uppercase tracking-[0.18em] text-muted">
Add LinkedIn source
</p>
<p className="m-0 text-sm leading-6 text-muted">
Create a project-scoped LinkedIn source without hand-writing config JSON.
</p>
</div>
<label className="grid gap-2">
<span className="text-sm font-medium text-foreground">Surface type</span>
<select
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"
defaultValue="organization"
name="surface"
>
<option value="organization">Organization page</option>
<option value="person">Person feed</option>
<option value="newsletter">Newsletter feed</option>
</select>
</label>
<label className="grid gap-2">
<span className="text-sm font-medium text-foreground">URN</span>
<input
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"
name="urn"
placeholder="urn:li:organization:1337"
required
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2">
<span className="text-sm font-medium text-foreground">Max posts per fetch</span>
<input
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"
defaultValue="50"
min="1"
name="max_posts_per_fetch"
type="number"
/>
</label>
<label className="grid gap-2">
<span className="text-sm font-medium text-foreground">Include reshares</span>
<select
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"
defaultValue="false"
name="include_reshares"
>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</label>
</div>
<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">
Add LinkedIn source
</button>
</form>

<section className="space-y-2 rounded-2xl border border-border/10 bg-muted/45 p-4">
<p className="m-0 text-sm font-semibold uppercase tracking-[0.18em] text-muted">
Quick config shapes
</p>
<p className="m-0 text-sm leading-6 text-muted">
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>.
</p>
<p className="m-0 text-sm leading-6 text-muted">
The generic source editor below still works for advanced payloads, but most projects should be able to onboard LinkedIn surfaces from this form alone.
</p>
</section>
</div>
</article>

<article className="space-y-4 rounded-3xl border border-border/12 bg-card/85 p-5 shadow-panel backdrop-blur-xl">
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/app/api/entity-candidates/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ function createCandidate(overrides: Partial<EntityCandidate> = {}): EntityCandid
first_seen_in: 21,
first_seen_title: "River Labs launches hosted platform",
occurrence_count: 2,
cluster_key: "cluster-9",
auto_promotion_blocked_reason: "needs_more_occurrences",
evidence_count: 2,
source_plugin_count: 2,
source_plugins: ["linkedin", "rss"],
identity_surfaces: ["linkedin"],
status: "pending",
merged_into: null,
merged_into_name: "",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from "vitest"

import {
createProjectConfig,
recomputeProjectConfigAuthority,
updateProjectConfig,
} from "@/lib/api"

import { POST } from "./route"

vi.mock("@/lib/api", () => ({
createProjectConfig: vi.fn(),
recomputeProjectConfigAuthority: vi.fn(),
updateProjectConfig: vi.fn(),
}))

describe("POST /api/projects/[id]/authority-settings", () => {
beforeEach(() => {
vi.clearAllMocks()
})

function buildFormData() {
const formData = new FormData()
formData.set("draft_schedule_cron", "")
formData.set("authority_weight_mention", "0.2")
formData.set("authority_weight_engagement", "0.15")
formData.set("authority_weight_recency", "0.15")
formData.set("authority_weight_source_quality", "0.15")
formData.set("authority_weight_cross_newsletter", "0.2")
formData.set("authority_weight_feedback", "0.1")
formData.set("authority_weight_duplicate", "0.05")
formData.set("upvote_authority_weight", "0.05")
formData.set("downvote_authority_weight", "-0.05")
formData.set("authority_decay_rate", "0.9")
return formData
}

it("creates a config and returns JSON for a save request", async () => {
vi.mocked(createProjectConfig).mockResolvedValue({ id: 7 } as never)

const response = await POST(
new Request("http://localhost/api/projects/4/authority-settings?mode=json", {
method: "POST",
body: buildFormData(),
}),
{
params: Promise.resolve({ id: "4" }),
},
)

expect(createProjectConfig).toHaveBeenCalledWith(
4,
expect.objectContaining({ authority_weight_engagement: 0.15 }),
)
expect(updateProjectConfig).not.toHaveBeenCalled()
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
configId: 7,
message: "Authority weights saved.",
})
})

it("updates and recomputes when requested", async () => {
vi.mocked(updateProjectConfig).mockResolvedValue({ id: 9 } as never)
vi.mocked(recomputeProjectConfigAuthority).mockResolvedValue({
status: "completed",
project_id: 4,
config_id: 9,
})

const formData = buildFormData()
formData.set("configId", "9")
formData.set("intent", "save_and_recompute")

const response = await POST(
new Request("http://localhost/api/projects/4/authority-settings?mode=json", {
method: "POST",
body: formData,
}),
{
params: Promise.resolve({ id: "4" }),
},
)

expect(updateProjectConfig).toHaveBeenCalledWith(
4,
9,
expect.objectContaining({ authority_weight_source_quality: 0.15 }),
)
expect(recomputeProjectConfigAuthority).toHaveBeenCalledWith(4, 9)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
configId: 9,
message: "Authority weights saved and recomputed.",
})
})
})
Loading
Loading